Files

156 lines
6.7 KiB
TypeScript

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<unknown>): Promise<string | undefined> {
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);
});
});