File
Blob: tests/worker/account-linking.test.ts
| 1 | import { env, exports } from "cloudflare:workers"; |
| 2 | import { eq } from "drizzle-orm"; |
| 3 | import { beforeAll, describe, expect, it } from "vitest"; |
| 4 | |
| 5 | const SELF = exports.default; |
| 6 | |
| 7 | import { makeAuth } from "@/worker/auth"; |
| 8 | import { makeDb } from "@/worker/db"; |
| 9 | import { accounts, users } from "@/worker/db/schema"; |
| 10 | |
| 11 | import { ISSUER, signInForCookie, testHeaders } from "./helpers"; |
| 12 | |
| 13 | describe("account linking", () => { |
| 14 | const credential = { |
| 15 | email: "linker@example.com", |
| 16 | password: "correct-horse-battery-staple", |
| 17 | name: "Linker", |
| 18 | }; |
| 19 | |
| 20 | beforeAll(async () => { |
| 21 | const auth = makeAuth(env, { baseURL: ISSUER, issuer: ISSUER }); |
| 22 | await auth.api.signUpEmail({ body: credential, asResponse: false }); |
| 23 | }); |
| 24 | |
| 25 | it("lists the credential account, gates last-account unlink, and removes a linked github account", async () => { |
| 26 | const db = makeDb(env); |
| 27 | const cookie = await signInForCookie(credential.email, credential.password, "10.20.0.1"); |
| 28 | |
| 29 | // 1. Baseline: only the credential account exists. |
| 30 | const listRes1 = await SELF.fetch(`${ISSUER}/api/auth/list-accounts`, { |
| 31 | headers: { cookie }, |
| 32 | }); |
| 33 | expect(listRes1.status).toBe(200); |
| 34 | const accounts1 = (await listRes1.json()) as Array<{ id: string; providerId: string; accountId: string }>; |
| 35 | expect(accounts1).toHaveLength(1); |
| 36 | expect(accounts1[0]?.providerId).toBe("credential"); |
| 37 | |
| 38 | // 2. Unlinking the only account is rejected โ this is what guards a user |
| 39 | // from accidentally locking themselves out of their own session. |
| 40 | // Origin is required by Better Auth's CSRF gate for state-changing |
| 41 | // auth endpoints; without it we get 403 MISSING_OR_NULL_ORIGIN before |
| 42 | // the unlink logic runs. |
| 43 | const unlinkOnly = await SELF.fetch(`${ISSUER}/api/auth/unlink-account`, { |
| 44 | method: "POST", |
| 45 | headers: testHeaders({ cookie }), |
| 46 | body: JSON.stringify({ accountId: accounts1[0]?.id }), |
| 47 | }); |
| 48 | expect(unlinkOnly.status).toBe(400); |
| 49 | const unlinkOnlyBody = (await unlinkOnly.json()) as { code?: string }; |
| 50 | expect(unlinkOnlyBody.code).toBe("FAILED_TO_UNLINK_LAST_ACCOUNT"); |
| 51 | |
| 52 | // 3. Simulate a completed GitHub link by inserting an account row |
| 53 | // directly. The real flow goes through Better Auth's OAuth callback, |
| 54 | // which we can't drive against the live GitHub IdP from a test โ |
| 55 | // inserting the row mirrors the post-callback database state so we |
| 56 | // can exercise the surfaces tessera owns: list + unlink. |
| 57 | const userRow = await db.select({ id: users.id }).from(users).where(eq(users.email, credential.email)).get(); |
| 58 | if (!userRow) throw new Error("seed user missing"); |
| 59 | const githubAccountId = crypto.randomUUID(); |
| 60 | await db.insert(accounts).values({ |
| 61 | id: githubAccountId, |
| 62 | providerId: "github", |
| 63 | accountId: "github-12345", |
| 64 | userId: userRow.id, |
| 65 | accessToken: "fake-token-encrypted-by-better-auth-on-write", |
| 66 | createdAt: new Date(), |
| 67 | updatedAt: new Date(), |
| 68 | }); |
| 69 | |
| 70 | const listRes2 = await SELF.fetch(`${ISSUER}/api/auth/list-accounts`, { |
| 71 | headers: { cookie }, |
| 72 | }); |
| 73 | const accounts2 = (await listRes2.json()) as Array<{ providerId: string; accountId: string }>; |
| 74 | expect(accounts2).toHaveLength(2); |
| 75 | const providerIds = accounts2.map((a) => a.providerId).sort(); |
| 76 | expect(providerIds).toEqual(["credential", "github"]); |
| 77 | |
| 78 | // 4. Unlinking github (a non-credential, non-last account) succeeds. |
| 79 | const unlinkGithub = await SELF.fetch(`${ISSUER}/api/auth/unlink-account`, { |
| 80 | method: "POST", |
| 81 | headers: testHeaders({ cookie }), |
| 82 | body: JSON.stringify({ accountId: githubAccountId }), |
| 83 | }); |
| 84 | expect(unlinkGithub.status).toBe(200); |
| 85 | const unlinkBody = (await unlinkGithub.json()) as { status: boolean }; |
| 86 | expect(unlinkBody.status).toBe(true); |
| 87 | |
| 88 | // 5. Post-unlink: only credential remains, and the protection from step 2 |
| 89 | // re-applies. |
| 90 | const listRes3 = await SELF.fetch(`${ISSUER}/api/auth/list-accounts`, { |
| 91 | headers: { cookie }, |
| 92 | }); |
| 93 | const accounts3 = (await listRes3.json()) as Array<{ providerId: string }>; |
| 94 | expect(accounts3).toHaveLength(1); |
| 95 | expect(accounts3[0]?.providerId).toBe("credential"); |
| 96 | }, 30_000); |
| 97 | |
| 98 | it("rejects list-accounts and unlink-account without a session", async () => { |
| 99 | const listRes = await SELF.fetch(`${ISSUER}/api/auth/list-accounts`); |
| 100 | expect(listRes.status).toBe(401); |
| 101 | |
| 102 | const unlinkRes = await SELF.fetch(`${ISSUER}/api/auth/unlink-account`, { |
| 103 | method: "POST", |
| 104 | headers: testHeaders(), |
| 105 | body: JSON.stringify({ accountId: "missing-account" }), |
| 106 | }); |
| 107 | expect(unlinkRes.status).toBe(401); |
| 108 | }); |
| 109 | |
| 110 | // tessera's social providers stay unconfigured in tests (no |
| 111 | // GITHUB/GOOGLE client id env). The social sign-in route is the |
| 112 | // surface where `requestSignUp: true` could otherwise bypass |
| 113 | // invite-only signup. With `disableSignUp: true` set per provider, |
| 114 | // the route must reject the request before any user creation |
| 115 | // attempt โ either as "provider not configured" or with a |
| 116 | // signup_disabled error, never with a created user. |
| 117 | it("never creates a user via /sign-in/social with requestSignUp: true", async () => { |
| 118 | const before = await SELF.fetch(`${ISSUER}/api/auth/sign-in/social`, { |
| 119 | method: "POST", |
| 120 | headers: testHeaders(), |
| 121 | body: JSON.stringify({ |
| 122 | provider: "github", |
| 123 | callbackURL: "/account", |
| 124 | requestSignUp: true, |
| 125 | }), |
| 126 | }); |
| 127 | expect(before.status).not.toBe(200); |
| 128 | expect(before.headers.get("set-cookie")).toBeNull(); |
| 129 | }); |
| 130 | }); |