import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { createDb, type AppDb } from "../src/server/db"; import { createAuth, type AuthHandlers } from "../src/server/auth"; /** * M4 auth tests (WorkBreakdown M4): exercised through the exported handlers * against an in-memory bun:sqlite database — hermetic, no server socket, and * the on-disk default (./data/app.db) is never touched because createAuth * takes an injected database getter. */ let db: AppDb; let auth: AuthHandlers; beforeAll(() => { db = createDb(":memory:"); auth = createAuth(() => db); }); afterAll(() => db.close()); // --- Request helpers -------------------------------------------------------- const BASE = "http://localhost"; function postJson(path: string, body: unknown, cookie?: string): Request { return new Request(`${BASE}${path}`, { method: "POST", headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}), }, body: JSON.stringify(body), }); } function get(path: string, cookie?: string): Request { return new Request(`${BASE}${path}`, { headers: cookie ? { cookie } : {}, }); } /** Extracts "sid=" from a Set-Cookie header for replay in later requests. */ function sidCookie(response: Response): string { const header = response.headers.get("set-cookie"); expect(header).toBeTruthy(); const match = /(?:^|;\s*)sid=([^;]+)/.exec(header!); expect(match).toBeTruthy(); return `sid=${match![1]}`; } async function registerUser(username: string, password = "hunter2secret") { const response = await auth.register(postJson("/api/auth/register", { username, password })); expect(response.status).toBe(201); return response; } // --- Register ---------------------------------------------------------------- describe("POST /api/auth/register", () => { test("201 with user body and session cookie", async () => { const response = await registerUser("alice"); expect(response.headers.get("content-type")).toStartWith("application/json"); const body = await response.json(); expect(body.user.username).toBe("alice"); expect(typeof body.user.id).toBe("number"); // Password material must never leak into the response. expect(JSON.stringify(body)).not.toContain("hash"); const cookie = response.headers.get("set-cookie")!; expect(cookie).toContain("sid="); expect(cookie).toContain("HttpOnly"); expect(cookie).toContain("SameSite=Lax"); expect(cookie).toContain("Path=/"); expect(cookie).toContain("Max-Age=604800"); // Not production in tests, so no Secure attribute. expect(cookie).not.toContain("Secure"); }); test("password is stored as an Argon2 hash, not plaintext", () => { const user = db.findUserByUsername("alice"); expect(user).not.toBeNull(); expect(user!.passwordHash).toStartWith("$argon2"); }); test("409 for a duplicate username", async () => { const response = await auth.register( postJson("/api/auth/register", { username: "alice", password: "hunter2secret" }), ); expect(response.status).toBe(409); expect((await response.json()).error).toBeString(); }); test.each([ ["short username", { username: "ab", password: "hunter2secret" }], ["long username", { username: "x".repeat(33), password: "hunter2secret" }], ["bad username characters", { username: "not ok!", password: "hunter2secret" }], ["short password", { username: "bob", password: "1234567" }], ["missing password", { username: "bob" }], ["non-string fields", { username: 42, password: 12345678 }], ])("400 for %s", async (_label, body) => { const response = await auth.register(postJson("/api/auth/register", body)); expect(response.status).toBe(400); expect((await response.json()).error).toBeString(); }); test("400 for a non-JSON body", async () => { const response = await auth.register( new Request(`${BASE}/api/auth/register`, { method: "POST", body: "not json" }), ); expect(response.status).toBe(400); }); }); // --- Login -------------------------------------------------------------------- describe("POST /api/auth/login", () => { test("200 with user body and fresh session cookie", async () => { const response = await auth.login( postJson("/api/auth/login", { username: "alice", password: "hunter2secret" }), ); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toStartWith("application/json"); expect((await response.json()).user.username).toBe("alice"); expect(sidCookie(response)).toStartWith("sid="); }); test("401 with the same message for wrong password and unknown user", async () => { const wrongPassword = await auth.login( postJson("/api/auth/login", { username: "alice", password: "wrong-password" }), ); const unknownUser = await auth.login( postJson("/api/auth/login", { username: "nobody", password: "hunter2secret" }), ); expect(wrongPassword.status).toBe(401); expect(unknownUser.status).toBe(401); // Identical bodies: responses must not reveal which part was wrong. expect(await wrongPassword.json()).toEqual({ error: "invalid credentials" }); expect(await unknownUser.json()).toEqual({ error: "invalid credentials" }); }); }); // --- Session lifecycle (/me, logout, expiry) ----------------------------------- describe("GET /api/auth/me + logout + expiry", () => { test("200 with a valid session cookie", async () => { const registered = await registerUser("carol"); const cookie = sidCookie(registered); const response = await auth.me(get("/api/auth/me", cookie)); expect(response.status).toBe(200); expect((await response.json()).user.username).toBe("carol"); }); test("401 without a cookie", async () => { const response = await auth.me(get("/api/auth/me")); expect(response.status).toBe(401); expect((await response.json()).error).toBeString(); }); test("401 with a garbage session id", async () => { const response = await auth.me(get("/api/auth/me", "sid=definitely-not-a-session")); expect(response.status).toBe(401); }); test("logout deletes the session and clears the cookie; /me then 401s", async () => { const registered = await registerUser("dave"); const cookie = sidCookie(registered); expect((await auth.me(get("/api/auth/me", cookie))).status).toBe(200); const logout = await auth.logout(postJson("/api/auth/logout", {}, cookie)); expect(logout.status).toBe(204); expect(logout.headers.get("set-cookie")).toContain("Max-Age=0"); // The session row is gone server-side, so replaying the old cookie fails. const me = await auth.me(get("/api/auth/me", cookie)); expect(me.status).toBe(401); }); test("logout without a session is still 204 (idempotent)", async () => { const response = await auth.logout(postJson("/api/auth/logout", {})); expect(response.status).toBe(204); }); test("expired sessions are rejected server-side", async () => { await registerUser("erin"); const user = db.findUserByUsername("erin")!; // Forge a session that expired a minute ago, exactly as a stale row would // look — the browser would happily still send this cookie. const expired = db.createSession(user.id, Date.now() - 60_000); const response = await auth.me(get("/api/auth/me", `sid=${expired.id}`)); expect(response.status).toBe(401); }); test("deleteExpiredSessions sweeps stale rows and keeps live ones", async () => { const user = db.findUserByUsername("erin")!; // Live first: createSession itself sweeps expired rows opportunistically, // so creating the stale row last keeps it alive until the explicit sweep. const live = db.createSession(user.id, Date.now() + 60_000); const stale = db.createSession(user.id, Date.now() - 1); expect(db.deleteExpiredSessions()).toBeGreaterThanOrEqual(1); expect(db.findValidSession(stale.id)).toBeNull(); expect(db.findValidSession(live.id)).not.toBeNull(); }); }); // --- getSessionUser (reused by the M5 transcribe guard) ------------------------ describe("getSessionUser", () => { test("returns {id, username} for a valid cookie and null otherwise", async () => { const registered = await registerUser("frank"); const cookie = sidCookie(registered); const user = auth.getSessionUser(get("/anything", cookie)); expect(user).toEqual({ id: expect.any(Number), username: "frank" }); expect(auth.getSessionUser(get("/anything"))).toBeNull(); expect(auth.getSessionUser(get("/anything", "sid=nope"))).toBeNull(); }); });