Skip to content
File

Blob: tests/worker/account-linking.test.ts

typescript131 lines
1import { env, exports } from "cloudflare:workers";
2import { eq } from "drizzle-orm";
3import { beforeAll, describe, expect, it } from "vitest";
4 
5const SELF = exports.default;
6 
7import { makeAuth } from "@/worker/auth";
8import { makeDb } from "@/worker/db";
9import { accounts, users } from "@/worker/db/schema";
10 
11import { ISSUER, signInForCookie, testHeaders } from "./helpers";
12 
13describe("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});