import { expect, test } from "bun:test"; import { forEachDialect } from "@sttracker/db/testing"; import { createAppToken, createInvite, getSessionUser, login, logout, registerUser, revokeAppToken, verifyAppToken, } from "../src/auth.ts"; import { AuthError } from "../src/errors.ts"; async function code(p: Promise): Promise { try { await p; } catch (e) { return e instanceof AuthError ? e.code : "other"; } return undefined; } forEachDialect("auth", (getDb) => { test("first user is admin and username is lowercased", async () => { const u = await registerUser(getDb(), { username: "Alice", password: "pw-one" }); expect(u.role).toBe("admin"); expect(u.username).toBe("alice"); expect(u.displayName).toBe("alice"); }); test("invalid and duplicate usernames are rejected", async () => { const db = getDb(); expect(await code(registerUser(db, { username: "a b", password: "x" }))).toBe("invalid_username"); await registerUser(db, { username: "alice", password: "x" }); const inv = await createInvite(db, (await login(db, "alice", "x")).user.id); expect(await code(registerUser(db, { username: "ALICE", password: "x", inviteCode: inv }))).toBe( "username_taken", ); }); test("second user needs a single-use invite", async () => { const db = getDb(); const admin = await registerUser(db, { username: "alice", password: "x" }); expect(await code(registerUser(db, { username: "bob", password: "x" }))).toBe("invite_required"); expect(await code(registerUser(db, { username: "bob", password: "x", inviteCode: "nope" }))).toBe( "invite_required", ); const inv = await createInvite(db, admin.id); const bob = await registerUser(db, { username: "bob", password: "x", inviteCode: inv }); expect(bob.role).toBe("member"); expect(await code(registerUser(db, { username: "carol", password: "x", inviteCode: inv }))).toBe( "invite_required", ); }); test("expired invite is rejected; members cannot create invites", async () => { const db = getDb(); const admin = await registerUser(db, { username: "alice", password: "x" }); const inv = await createInvite(db, admin.id, -1); expect(await code(registerUser(db, { username: "bob", password: "x", inviteCode: inv }))).toBe( "invite_required", ); const ok = await createInvite(db, admin.id); const bob = await registerUser(db, { username: "bob", password: "x", inviteCode: ok }); expect(await code(createInvite(db, bob.id))).toBe("forbidden"); }); test("wrong password or unknown user -> invalid_credentials", async () => { const db = getDb(); await registerUser(db, { username: "alice", password: "right" }); expect(await code(login(db, "alice", "wrong"))).toBe("invalid_credentials"); expect(await code(login(db, "nobody", "x"))).toBe("invalid_credentials"); }); test("session round-trip and logout", async () => { const db = getDb(); await registerUser(db, { username: "alice", password: "pw" }); const { user, sessionToken } = await login(db, "Alice", "pw"); expect(await getSessionUser(db, sessionToken)).toEqual(user); const stored = await db.k.selectFrom("sessions").selectAll().execute(); expect(stored[0]!.token_hash).not.toBe(sessionToken); await logout(db, sessionToken); expect(await getSessionUser(db, sessionToken)).toBeNull(); expect(await getSessionUser(db, "garbage")).toBeNull(); }); test("expired session returns null", async () => { const db = getDb(); await registerUser(db, { username: "alice", password: "pw" }); const { sessionToken } = await login(db, "alice", "pw"); await db.k.updateTable("sessions").set({ expires_at: new Date(Date.now() - 1000).toISOString() }).execute(); expect(await getSessionUser(db, sessionToken)).toBeNull(); }); test("app tokens: scope check, last_used_at, revoke", async () => { const db = getDb(); const u = await registerUser(db, { username: "alice", password: "pw" }); const { id, token } = await createAppToken(db, u.id, "cli", ["read"]); expect(token.startsWith("stt_")).toBe(true); expect(await verifyAppToken(db, token, "write")).toBeNull(); expect(await verifyAppToken(db, token, "read")).toEqual(u); const row = await db.k.selectFrom("app_tokens").selectAll().executeTakeFirstOrThrow(); expect(row.last_used_at).not.toBeNull(); expect(await verifyAppToken(db, "stt_bogus", "read")).toBeNull(); await revokeAppToken(db, u.id, id); expect(await verifyAppToken(db, token, "read")).toBeNull(); }); test("concurrent first registrations yield exactly one admin", async () => { const db = getDb(); const results = await Promise.allSettled([ registerUser(db, { username: "alice", password: "x" }), registerUser(db, { username: "bob", password: "x" }), ]); const ok = results.filter((r) => r.status === "fulfilled"); const bad = results.filter((r) => r.status === "rejected"); expect(ok.length).toBe(1); expect((ok[0] as PromiseFulfilledResult<{ role: string }>).value.role).toBe("admin"); expect(((bad[0] as PromiseRejectedResult).reason as AuthError).code).toBe("invite_required"); const admins = await db.k.selectFrom("users").select("id").where("role", "=", "admin").execute(); expect(admins.length).toBe(1); }); test("no invite means invite_required even for a taken username", async () => { const db = getDb(); await registerUser(db, { username: "alice", password: "x" }); expect(await code(registerUser(db, { username: "alice", password: "x" }))).toBe("invite_required"); }); test("only hashes are stored for invites and app tokens", async () => { const db = getDb(); const u = await registerUser(db, { username: "alice", password: "x" }); const inv = await createInvite(db, u.id); const { token } = await createAppToken(db, u.id, "cli", ["read"]); const invRow = await db.k.selectFrom("invites").selectAll().executeTakeFirstOrThrow(); const tokRow = await db.k.selectFrom("app_tokens").selectAll().executeTakeFirstOrThrow(); expect(invRow.code_hash).not.toBe(inv); expect(tokRow.token_hash).not.toBe(token); expect(tokRow.token_hash).not.toContain(token); }); test("revoking with another user's id leaves the token valid", async () => { const db = getDb(); const a = await registerUser(db, { username: "alice", password: "x" }); const b = await registerUser(db, { username: "bob", password: "x", inviteCode: await createInvite(db, a.id), }); const { id, token } = await createAppToken(db, a.id, "cli", ["read"]); await revokeAppToken(db, b.id, id); expect(await verifyAppToken(db, token, "read")).toEqual(a); }); });