File
Blob: tests/worker/bootstrap-admin.test.ts
| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { eq, sql } from "drizzle-orm"; |
| 3 | import { beforeEach, describe, expect, it } from "vitest"; |
| 4 | |
| 5 | import { makeAuth } from "@/worker/auth"; |
| 6 | import { makeDb } from "@/worker/db"; |
| 7 | import { accounts, sessions, users } from "@/worker/db/schema"; |
| 8 | |
| 9 | import { ISSUER } from "./helpers"; |
| 10 | |
| 11 | const fetchRoleByEmail = async (email: string): Promise<string | null> => { |
| 12 | const db = makeDb(env); |
| 13 | const row = await db.select({ role: users.role }).from(users).where(eq(users.email, email)).get(); |
| 14 | return row?.role ?? null; |
| 15 | }; |
| 16 | |
| 17 | const wipeUsers = async (): Promise<void> => { |
| 18 | const db = makeDb(env); |
| 19 | // Order matters: accounts/sessions FK -> users. Cascade should handle it, |
| 20 | // but be explicit for clarity in this fixture. |
| 21 | await db.delete(sessions).where(sql`1 = 1`); |
| 22 | await db.delete(accounts).where(sql`1 = 1`); |
| 23 | await db.delete(users).where(sql`1 = 1`); |
| 24 | }; |
| 25 | |
| 26 | // The bootstrap-admin promotion hook is gated on userCount === 0 so a |
| 27 | // stale BOOTSTRAP_ADMIN_EMAIL is safe to leave configured. Each test |
| 28 | // clears the users table first to make assertions on the "first signup" |
| 29 | // branch unambiguous. |
| 30 | describe("bootstrap admin promotion", () => { |
| 31 | beforeEach(wipeUsers); |
| 32 | |
| 33 | it("promotes the email matching BOOTSTRAP_ADMIN_EMAIL on the very first signup", async () => { |
| 34 | const adminEmail = "bootstrap-admin@example.com"; |
| 35 | const auth = makeAuth({ ...env, BOOTSTRAP_ADMIN_EMAIL: adminEmail } as Env, { baseURL: ISSUER, issuer: ISSUER }); |
| 36 | await auth.api.signUpEmail({ |
| 37 | body: { email: adminEmail, password: "correct-horse-battery-staple", name: "Bootstrap" }, |
| 38 | asResponse: false, |
| 39 | }); |
| 40 | expect(await fetchRoleByEmail(adminEmail)).toBe("admin"); |
| 41 | }, 15_000); |
| 42 | |
| 43 | it("does NOT promote a matching email when users already exist (stale env safety)", async () => { |
| 44 | const adminEmail = "stale-admin@example.com"; |
| 45 | // Seed an unrelated user so userCount > 0 before the BOOTSTRAP signup. |
| 46 | const auth = makeAuth({ ...env, BOOTSTRAP_ADMIN_EMAIL: "" } as Env, { baseURL: ISSUER, issuer: ISSUER }); |
| 47 | await auth.api.signUpEmail({ |
| 48 | body: { email: "first-user@example.com", password: "correct-horse-battery-staple", name: "First" }, |
| 49 | asResponse: false, |
| 50 | }); |
| 51 | expect(await fetchRoleByEmail("first-user@example.com")).toBe("user"); |
| 52 | |
| 53 | // Now sign up the BOOTSTRAP_ADMIN_EMAIL — must be `user`, not `admin`. |
| 54 | const authWithStaleEnv = makeAuth({ ...env, BOOTSTRAP_ADMIN_EMAIL: adminEmail } as Env, { |
| 55 | baseURL: ISSUER, |
| 56 | issuer: ISSUER, |
| 57 | }); |
| 58 | await authWithStaleEnv.api.signUpEmail({ |
| 59 | body: { email: adminEmail, password: "correct-horse-battery-staple", name: "Stale" }, |
| 60 | asResponse: false, |
| 61 | }); |
| 62 | expect(await fetchRoleByEmail(adminEmail)).toBe("user"); |
| 63 | }, 30_000); |
| 64 | |
| 65 | it("does not promote a non-matching email even when BOOTSTRAP_ADMIN_EMAIL is set", async () => { |
| 66 | const adminEmail = "intended-admin@example.com"; |
| 67 | const otherEmail = "not-the-admin@example.com"; |
| 68 | const auth = makeAuth({ ...env, BOOTSTRAP_ADMIN_EMAIL: adminEmail } as Env, { baseURL: ISSUER, issuer: ISSUER }); |
| 69 | await auth.api.signUpEmail({ |
| 70 | body: { email: otherEmail, password: "correct-horse-battery-staple", name: "Other" }, |
| 71 | asResponse: false, |
| 72 | }); |
| 73 | expect(await fetchRoleByEmail(otherEmail)).toBe("user"); |
| 74 | }, 15_000); |
| 75 | |
| 76 | it("matches BOOTSTRAP_ADMIN_EMAIL case-insensitively on first signup", async () => { |
| 77 | const adminEmail = "Mixed-Case-Admin@Example.com"; |
| 78 | const auth = makeAuth({ ...env, BOOTSTRAP_ADMIN_EMAIL: "mixed-case-admin@example.com" } as Env, { |
| 79 | baseURL: ISSUER, |
| 80 | issuer: ISSUER, |
| 81 | }); |
| 82 | await auth.api.signUpEmail({ |
| 83 | body: { email: adminEmail, password: "correct-horse-battery-staple", name: "Mixed" }, |
| 84 | asResponse: false, |
| 85 | }); |
| 86 | // Better Auth normalises emails to lowercase before insert. |
| 87 | expect(await fetchRoleByEmail(adminEmail.toLowerCase())).toBe("admin"); |
| 88 | }, 15_000); |
| 89 | |
| 90 | it("leaves new users as role=user when BOOTSTRAP_ADMIN_EMAIL is empty", async () => { |
| 91 | const email = "no-bootstrap@example.com"; |
| 92 | const auth = makeAuth({ ...env, BOOTSTRAP_ADMIN_EMAIL: "" } as Env, { baseURL: ISSUER, issuer: ISSUER }); |
| 93 | await auth.api.signUpEmail({ |
| 94 | body: { email, password: "correct-horse-battery-staple", name: "Plain" }, |
| 95 | asResponse: false, |
| 96 | }); |
| 97 | expect(await fetchRoleByEmail(email)).toBe("user"); |
| 98 | }, 15_000); |
| 99 | }); |