import { env, exports } from "cloudflare:workers"; import { eq } from "drizzle-orm"; import { beforeAll, describe, expect, it } from "vitest"; const SELF = exports.default; import { makeAuth } from "@/worker/auth"; import { makeDb } from "@/worker/db"; import { accounts, users } from "@/worker/db/schema"; import { ISSUER, signInForCookie, testHeaders } from "./helpers"; describe("account linking", () => { const credential = { email: "linker@example.com", password: "correct-horse-battery-staple", name: "Linker", }; beforeAll(async () => { const auth = makeAuth(env, { baseURL: ISSUER, issuer: ISSUER }); await auth.api.signUpEmail({ body: credential, asResponse: false }); }); it("lists the credential account, gates last-account unlink, and removes a linked github account", async () => { const db = makeDb(env); const cookie = await signInForCookie(credential.email, credential.password, "10.20.0.1"); // 1. Baseline: only the credential account exists. const listRes1 = await SELF.fetch(`${ISSUER}/api/auth/list-accounts`, { headers: { cookie }, }); expect(listRes1.status).toBe(200); const accounts1 = (await listRes1.json()) as Array<{ id: string; providerId: string; accountId: string }>; expect(accounts1).toHaveLength(1); expect(accounts1[0]?.providerId).toBe("credential"); // 2. Unlinking the only account is rejected — this is what guards a user // from accidentally locking themselves out of their own session. // Origin is required by Better Auth's CSRF gate for state-changing // auth endpoints; without it we get 403 MISSING_OR_NULL_ORIGIN before // the unlink logic runs. const unlinkOnly = await SELF.fetch(`${ISSUER}/api/auth/unlink-account`, { method: "POST", headers: testHeaders({ cookie }), body: JSON.stringify({ accountId: accounts1[0]?.id }), }); expect(unlinkOnly.status).toBe(400); const unlinkOnlyBody = (await unlinkOnly.json()) as { code?: string }; expect(unlinkOnlyBody.code).toBe("FAILED_TO_UNLINK_LAST_ACCOUNT"); // 3. Simulate a completed GitHub link by inserting an account row // directly. The real flow goes through Better Auth's OAuth callback, // which we can't drive against the live GitHub IdP from a test — // inserting the row mirrors the post-callback database state so we // can exercise the surfaces tessera owns: list + unlink. const userRow = await db.select({ id: users.id }).from(users).where(eq(users.email, credential.email)).get(); if (!userRow) throw new Error("seed user missing"); const githubAccountId = crypto.randomUUID(); await db.insert(accounts).values({ id: githubAccountId, providerId: "github", accountId: "github-12345", userId: userRow.id, accessToken: "fake-token-encrypted-by-better-auth-on-write", createdAt: new Date(), updatedAt: new Date(), }); const listRes2 = await SELF.fetch(`${ISSUER}/api/auth/list-accounts`, { headers: { cookie }, }); const accounts2 = (await listRes2.json()) as Array<{ providerId: string; accountId: string }>; expect(accounts2).toHaveLength(2); const providerIds = accounts2.map((a) => a.providerId).sort(); expect(providerIds).toEqual(["credential", "github"]); // 4. Unlinking github (a non-credential, non-last account) succeeds. const unlinkGithub = await SELF.fetch(`${ISSUER}/api/auth/unlink-account`, { method: "POST", headers: testHeaders({ cookie }), body: JSON.stringify({ accountId: githubAccountId }), }); expect(unlinkGithub.status).toBe(200); const unlinkBody = (await unlinkGithub.json()) as { status: boolean }; expect(unlinkBody.status).toBe(true); // 5. Post-unlink: only credential remains, and the protection from step 2 // re-applies. const listRes3 = await SELF.fetch(`${ISSUER}/api/auth/list-accounts`, { headers: { cookie }, }); const accounts3 = (await listRes3.json()) as Array<{ providerId: string }>; expect(accounts3).toHaveLength(1); expect(accounts3[0]?.providerId).toBe("credential"); }, 30_000); it("rejects list-accounts and unlink-account without a session", async () => { const listRes = await SELF.fetch(`${ISSUER}/api/auth/list-accounts`); expect(listRes.status).toBe(401); const unlinkRes = await SELF.fetch(`${ISSUER}/api/auth/unlink-account`, { method: "POST", headers: testHeaders(), body: JSON.stringify({ accountId: "missing-account" }), }); expect(unlinkRes.status).toBe(401); }); // tessera's social providers stay unconfigured in tests (no // GITHUB/GOOGLE client id env). The social sign-in route is the // surface where `requestSignUp: true` could otherwise bypass // invite-only signup. With `disableSignUp: true` set per provider, // the route must reject the request before any user creation // attempt — either as "provider not configured" or with a // signup_disabled error, never with a created user. it("never creates a user via /sign-in/social with requestSignUp: true", async () => { const before = await SELF.fetch(`${ISSUER}/api/auth/sign-in/social`, { method: "POST", headers: testHeaders(), body: JSON.stringify({ provider: "github", callbackURL: "/account", requestSignUp: true, }), }); expect(before.status).not.toBe(200); expect(before.headers.get("set-cookie")).toBeNull(); }); });