import { expect, spyOn, test } from "bun:test"; import { assignInitialRole, isUniqueViolation } from "../src/server/auth"; import { syncClaimGroups, groupsFromClaims } from "../src/server/auth/groups"; import { forEachDb } from "./helpers"; import { cookieFrom, makeApp } from "./app"; forEachDb("first sign-up becomes owner, later ones learners", async (db) => { const t = await makeApp(db); const ownerCookie = await t.signUp("owner@example.com"); const me = await (await t.call("GET", "/api/me", { cookie: ownerCookie })).json(); expect(me.user.email).toBe("owner@example.com"); expect(me.user.roles).toEqual(["owner"]); const second = await t.signUp("learner@example.com"); expect((await (await t.call("GET", "/api/me", { cookie: second })).json()).user.roles).toEqual(["learner"]); expect((await (await t.call("GET", "/api/me")).json()).user).toBeNull(); }, 20_000); forEachDb("sign in with password and sign out", async (db) => { const t = await makeApp(db); await t.signUp("a@example.com", "A", "pw-123456789"); const bad = await t.call("POST", "/api/auth/sign-in/email", { body: { email: "a@example.com", password: "nope-nope-nope" } }); expect(bad.status).toBe(401); const ok = await t.call("POST", "/api/auth/sign-in/email", { body: { email: "a@example.com", password: "pw-123456789" } }); expect(ok.status).toBe(200); const cookie = cookieFrom(ok); expect((await (await t.call("GET", "/api/me", { cookie })).json()).user.name).toBe("A"); const out = await t.call("POST", "/api/auth/sign-out", { cookie, body: {} }); expect(out.status).toBe(200); // the session row is gone: the old token no longer resolves once the cookie cache is dropped const sessionOnly = cookie.split("; ").filter((c) => !c.includes("session_data")).join("; "); expect((await (await t.call("GET", "/api/me", { cookie: sessionOnly })).json()).user).toBeNull(); }, 20_000); forEachDb("owner claim is exclusive under concurrency", async (db) => { const roles = await Promise.all(["u1", "u2", "u3", "u4"].map((u) => assignInitialRole(db, u))); expect(roles.filter((r) => r === "owner").length).toBe(1); }); forEachDb("SSO provider administration is owner-only", async (db) => { const t = await makeApp(db); await t.signUp("owner@example.com"); const learner = await t.signUp("l@example.com"); expect((await t.call("POST", "/api/auth/sso/register", { cookie: learner, body: {} })).status).toBe(403); expect((await t.call("POST", "/api/auth/sso/register", { body: {} })).status).toBe(403); }, 20_000); forEachDb("claim groups sync keeps manual memberships", async (db) => { await db.sql`INSERT INTO groups (id, name) VALUES ('staff', 'Staff')`; await db.sql`INSERT INTO group_members (group_id, user_id) VALUES ('staff', 'u1')`; await syncClaimGroups(db, "u1", ["Staff", "DevOps Team"]); const ids = async () => (await db.sql`SELECT group_id FROM group_members WHERE user_id = 'u1' ORDER BY group_id`).map((r: { group_id: string }) => r.group_id); expect(await ids()).toEqual(["devops-team", "staff"]); await syncClaimGroups(db, "u1", []); expect(await ids()).toEqual(["staff"]); // manual one stays, claim one goes }); test("groupsFromClaims reads arrays and strings", () => { expect(groupsFromClaims({ groups: ["a", "b", "a"] })).toEqual(["a", "b"]); expect(groupsFromClaims({ roles: "x, y" }, "roles")).toEqual(["x", "y"]); expect(groupsFromClaims({ extraFields: { groups: ["z"] } })).toEqual(["z"]); expect(groupsFromClaims(null)).toEqual([]); }); forEachDb("owner bootstrap fails closed: an injected failure refuses the sign-up, no silent learner, no free owner slot", async (db) => { const t = await makeApp(db); const count = async (q: string) => Number((await db.sql.unsafe(q))[0].n); const err = spyOn(console, "error").mockImplementation(() => {}); try { // The role table is unreachable: role assignment fails with a non-unique error. await db.sql.unsafe("ALTER TABLE user_roles RENAME TO user_roles_off"); const signUp = (email: string) => t.call("POST", "/api/auth/sign-up/email", { body: { email, name: "X", password: "correct horse battery" } }); const first = await signUp("first@example.com"); expect(first.status).toBe(500); // nothing is left: no account, no owner claim expect(await count("SELECT COUNT(*) AS n FROM auth_user")).toBe(0); expect(await count("SELECT COUNT(*) AS n FROM learner_meta WHERE key = 'owner'")).toBe(0); // while the failure lasts, the next sign-up does not become owner either: it fails the same way expect((await signUp("stranger@example.com")).status).toBe(500); expect(await count("SELECT COUNT(*) AS n FROM auth_user")).toBe(0); expect(await count("SELECT COUNT(*) AS n FROM learner_meta WHERE key = 'owner'")).toBe(0); // once the database recovers, the first completed account is the owner, the next a learner await db.sql.unsafe("ALTER TABLE user_roles_off RENAME TO user_roles"); const own = await t.signUp("first@example.com"); const lea = await t.signUp("stranger@example.com"); expect((await (await t.call("GET", "/api/me", { cookie: own })).json()).user.roles).toEqual(["owner"]); expect((await (await t.call("GET", "/api/me", { cookie: lea })).json()).user.roles).toEqual(["learner"]); } finally { err.mockRestore(); } }, 30_000); forEachDb("assignInitialRole rethrows anything but a unique violation, and rolls the owner claim back", async (db) => { await db.sql.unsafe("ALTER TABLE user_roles RENAME TO user_roles_off"); await expect(assignInitialRole(db, "u1")).rejects.toThrow(); expect((await db.sql`SELECT COUNT(*) AS n FROM learner_meta WHERE key = 'owner'`)[0].n.toString()).toBe("0"); await db.sql.unsafe("ALTER TABLE user_roles_off RENAME TO user_roles"); expect(await assignInitialRole(db, "u1")).toBe("owner"); expect(await assignInitialRole(db, "u2")).toBe("learner"); expect(isUniqueViolation({ code: "23505" })).toBe(true); expect(isUniqueViolation(new Error("UNIQUE constraint failed: learner_meta.key"))).toBe(true); expect(isUniqueViolation(new Error("no such table: user_roles"))).toBe(false); });