diff --git a/src/__tests__/audit.test.ts b/src/__tests__/audit.test.ts new file mode 100644 index 0000000..edad002 --- /dev/null +++ b/src/__tests__/audit.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import { recordAudit, getAuditLog, pruneAuditLogs } from "../lib/audit"; + +function createMockD1(): D1Database { + let rows: Array> = []; + let id = 1; + + function queryAll(sql: string, args: unknown[]): Array> { + if (sql.startsWith("INSERT")) return []; + let filtered = rows; + if (sql.includes("group_id = ?")) { + const gid = args[0] as string; + filtered = rows.filter((r) => r.group_id === gid); + } + const limit = args[args.length - 1] as number; + return [...filtered] + .sort((a, b) => (b.ts as number) - (a.ts as number)) + .slice(0, limit) + .map((r) => ({ + id: r.id, + ts: r.ts ?? 0, + actor_id: r.actor_id ?? null, + actor_login: r.actor_login ?? null, + action: r.action ?? "", + target_type: r.target_type ?? null, + target_id: r.target_id ?? null, + group_id: r.group_id ?? null, + detail: r.detail ?? null, + ip: r.ip ?? null, + })); + } + + const db = { + prepare: (sql: string): { bind: (args: unknown[]) => unknown } => ({ + bind: (...args: unknown[]) => ({ + run: async (): Promise<{ meta: { changes: number }; results?: unknown[] }> => { + if (sql.startsWith("INSERT")) { + const row: Record = { id: id++ }; + for (const [key, value] of [ + ["ts", args[0]], + ["actor_id", args[1]], + ["actor_login", args[2]], + ["action", args[3]], + ["target_type", args[4]], + ["target_id", args[5]], + ["group_id", args[6]], + ["detail", args[7]], + ["ip", args[8]], + ] as const) { + if (value != null) row[key] = value; + } + rows.push(row); + return { meta: { changes: 1 } }; + } + if (sql.startsWith("DELETE")) { + const cutoff = args[0] as number; + const before = rows.length; + rows = rows.filter((r) => (r.ts as number) >= cutoff); + return { meta: { changes: before - rows.length } }; + } + return { meta: { changes: 0 }, results: queryAll(sql, args) }; + }, + all: async (): Promise<{ results: Array> }> => ({ + results: queryAll(sql, args), + }), + }), + }), + } as unknown as D1Database; + return db; +} + +describe("audit log", () => { + let db: D1Database; + + beforeEach(() => { + db = createMockD1(); + }); + + it("records and loads entries newest-first", async () => { + await recordAudit(db, { + ts: 1000, + actorId: "1", + actorLogin: "a", + action: "group.create", + targetType: "group", + targetId: "g1", + groupId: "g1", + }); + await recordAudit(db, { + ts: 2000, + actorId: "2", + actorLogin: "b", + action: "invite.create", + groupId: "g2", + detail: { role: "viewer" }, + }); + const entries = await getAuditLog(db, { limit: 10 }); + expect(entries).toHaveLength(2); + expect(entries[0]!.action).toBe("invite.create"); + expect(entries[0]!.detail).toEqual({ role: "viewer" }); + expect(entries[1]!.actorLogin).toBe("a"); + }); + + it("filters by group", async () => { + await recordAudit(db, { ts: 1, actorLogin: "a", action: "x", groupId: "g1" }); + await recordAudit(db, { ts: 2, actorLogin: "b", action: "y", groupId: "g2" }); + const entries = await getAuditLog(db, { groupId: "g2" }); + expect(entries).toHaveLength(1); + expect(entries[0]!.action).toBe("y"); + }); + + it("prunes entries older than the retention window", async () => { + const now = Date.now(); + await recordAudit(db, { ts: now - 100 * 86400_000, actorLogin: "a", action: "old" }); + await recordAudit(db, { ts: now, actorLogin: "b", action: "new" }); + const removed = await pruneAuditLogs(db, 90); + expect(removed).toBe(1); + expect(await getAuditLog(db)).toHaveLength(1); + }); + + it("never throws on write failures", async () => { + const broken = { + prepare: (): { bind: () => { run: () => Promise } } => ({ + bind: () => ({ + run: async (): Promise => { + throw new Error("boom"); + }, + }), + }), + } as unknown as D1Database; + await expect(recordAudit(broken, { ts: 1, action: "x" })).resolves.toBeUndefined(); + }); +}); diff --git a/src/__tests__/groups.test.ts b/src/__tests__/groups.test.ts new file mode 100644 index 0000000..8659018 --- /dev/null +++ b/src/__tests__/groups.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "bun:test"; +import { + normalizeGroupMembers, + memberRole, + resolveScope, + roleAt, + roleAtLeast, + canEditRoutes, + canEditGroup, +} from "../web/groups"; +import type { Env, Group } from "../types"; + +function env(overrides: Partial = {}): Env { + return { GITHUB_WEBHOOK_SECRET: "s", KV: {} as KVNamespace, ...overrides }; +} + +const legacy: Group = { id: "g1", name: "Legacy", adminIds: ["octocat", "12345"] }; +const mixed: Group = { + id: "g2", + name: "Mixed", + adminIds: ["octocat"], + members: [ + { login: "octocat", role: "owner" }, + { login: "admin-bot", role: "admin" }, + { login: "Reader", role: "viewer" }, + ], +}; + +describe("normalizeGroupMembers", () => { + it("derives owners from legacy adminIds", () => { + expect(normalizeGroupMembers(legacy)).toEqual([ + { login: "octocat", role: "owner" }, + { login: "12345", role: "owner" }, + ]); + }); + + it("keeps members when present and validates roles", () => { + expect(normalizeGroupMembers(mixed)).toEqual([ + { login: "octocat", role: "owner" }, + { login: "admin-bot", role: "admin" }, + { login: "Reader", role: "viewer" }, + ]); + }); + + it("deduplicates case-insensitively", () => { + const g: Group = { + id: "g", + name: "G", + adminIds: [], + members: [ + { login: "Dup", role: "admin" }, + { login: "dup", role: "owner" }, + ], + }; + expect(normalizeGroupMembers(g)).toEqual([{ login: "Dup", role: "admin" }]); + }); + + it("falls back to owner for unknown roles", () => { + const g: Group = { + id: "g", + name: "G", + adminIds: [], + members: [{ login: "x", role: "sneaky" as never }], + }; + expect(normalizeGroupMembers(g)[0]!.role).toBe("owner"); + }); +}); + +describe("memberRole / resolveScope roles", () => { + it("resolves owner for legacy admins", () => { + expect(memberRole(legacy, "999", "octocat")).toBe("owner"); + expect(memberRole(legacy, "12345", "other")).toBe("owner"); + }); + + it("resolves admin/viewer for members", () => { + expect(memberRole(mixed, "1", "admin-bot")).toBe("admin"); + expect(memberRole(mixed, "1", "reader")).toBe("viewer"); + }); + + it("returns undefined for non-members", () => { + expect(memberRole(mixed, "1", "nobody")).toBeUndefined(); + }); + + it("super admins get owner everywhere", () => { + const scope = resolveScope(env({ ADMIN_USER_IDS: "1" }), [legacy, mixed], "1", "boss"); + expect(scope.isSuper).toBe(true); + expect(roleAt(scope, "g1")).toBe("owner"); + expect(roleAt(scope, "nope")).toBe("owner"); + }); + + it("regular users only see groups they belong to, with their role", () => { + const scope = resolveScope(env(), [legacy, mixed], "5", "admin-bot"); + expect(scope.groupIds.has("g1")).toBe(false); + expect(scope.groupIds.has("g2")).toBe(true); + expect(roleAt(scope, "g2")).toBe("admin"); + }); +}); + +describe("role helpers", () => { + it("ranks viewer < admin < owner", () => { + expect(roleAtLeast("viewer", "viewer")).toBe(true); + expect(roleAtLeast("admin", "viewer")).toBe(true); + expect(roleAtLeast("admin", "owner")).toBe(false); + expect(roleAtLeast(undefined, "viewer")).toBe(false); + }); + + it("canEditRoutes allows owner and admin", () => { + const scope = resolveScope(env(), [mixed], "1", "reader"); + expect(canEditRoutes(scope, "g2")).toBe(false); + expect(canEditGroup(scope, "g2")).toBe(false); + const scope2 = resolveScope(env(), [mixed], "1", "admin-bot"); + expect(canEditRoutes(scope2, "g2")).toBe(true); + expect(canEditGroup(scope2, "g2")).toBe(false); + const scope3 = resolveScope(env(), [mixed], "1", "octocat"); + expect(canEditRoutes(scope3, "g2")).toBe(true); + expect(canEditGroup(scope3, "g2")).toBe(true); + }); +}); diff --git a/src/__tests__/invites.test.ts b/src/__tests__/invites.test.ts new file mode 100644 index 0000000..41943e7 --- /dev/null +++ b/src/__tests__/invites.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import { createInvite, getInvite, listInvites, revokeInvite, acceptInvite } from "../web/invites"; +import { saveGroups, loadGroups } from "../web/groups"; +import type { Group } from "../types"; + +function createMockKV(): KVNamespace { + const store = new Map(); + return { + get: async (key: string, type?: string) => { + const entry = store.get(key); + if (!entry) return null; + if (entry.expiration && Date.now() / 1000 > entry.expiration) { + store.delete(key); + return null; + } + if (type === "json") return JSON.parse(entry.value); + return entry.value; + }, + put: async (key: string, value: string, opts?: { expirationTtl?: number }) => { + const expiration = opts?.expirationTtl ? Date.now() / 1000 + opts.expirationTtl : undefined; + store.set(key, { value, expiration }); + }, + delete: async (key: string) => { + store.delete(key); + }, + list: async () => ({ + keys: [...store.keys()].map((k) => ({ name: k })), + list_complete: true, + cacheStatus: null, + }), + } as unknown as KVNamespace; +} + +const group: Group = { + id: "team", + name: "Team", + adminIds: ["boss"], + members: [{ login: "boss", role: "owner" }], +}; + +describe("invites", () => { + let kv: KVNamespace; + + beforeEach(() => { + kv = createMockKV(); + }); + + it("creates and reads back an invite", async () => { + const token = await createInvite(kv, { + groupId: "team", + role: "admin", + expiresAt: Date.now() + 86400_000, + createdBy: "boss", + }); + const invite = await getInvite(kv, token); + expect(invite).toMatchObject({ groupId: "team", role: "admin", createdBy: "boss" }); + }); + + it("lists pending invites of a group only", async () => { + await createInvite(kv, { groupId: "team", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" }); + await createInvite(kv, { groupId: "other", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" }); + const invites = await listInvites(kv, "team"); + expect(invites).toHaveLength(1); + expect(invites[0]!.groupId).toBe("team"); + }); + + it("revokes an invite", async () => { + const token = await createInvite(kv, { groupId: "team", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" }); + await revokeInvite(kv, token); + expect(await getInvite(kv, token)).toBeNull(); + }); + + it("accept adds the user as a member and consumes the token", async () => { + await saveGroups(kv, [group]); + const token = await createInvite(kv, { groupId: "team", role: "admin", expiresAt: Date.now() + 86400_000, createdBy: "boss" }); + const result = await acceptInvite(kv, token, "777", "newbie"); + expect(result).toEqual({ ok: true, groupId: "team", role: "admin" }); + expect(await getInvite(kv, token)).toBeNull(); + const groups = await loadGroups(kv); + expect(groups[0]!.members).toContainEqual({ login: "newbie", role: "admin" }); + // adminIds stays in sync with owners only. + expect(groups[0]!.adminIds).toEqual(["boss"]); + }); + + it("upgrades an existing viewer to admin", async () => { + await saveGroups(kv, [ + { ...group, members: [{ login: "boss", role: "owner" }, { login: "newbie", role: "viewer" }] }, + ]); + const token = await createInvite(kv, { groupId: "team", role: "admin", expiresAt: Date.now() + 86400_000, createdBy: "boss" }); + await acceptInvite(kv, token, "777", "newbie"); + const groups = await loadGroups(kv); + expect(groups[0]!.members).toContainEqual({ login: "newbie", role: "admin" }); + }); + + it("rejects expired or unknown invites", async () => { + await saveGroups(kv, [group]); + const token = await createInvite(kv, { groupId: "team", role: "viewer", expiresAt: Date.now() - 1000, createdBy: "boss" }); + expect(await acceptInvite(kv, token, "1", "x")).toEqual({ ok: false, reason: "invalid" }); + expect(await acceptInvite(kv, "deadbeef", "1", "x")).toEqual({ ok: false, reason: "invalid" }); + }); + + it("rejects invites for missing groups", async () => { + await saveGroups(kv, []); + const token = await createInvite(kv, { groupId: "ghost", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" }); + expect(await acceptInvite(kv, token, "1", "x")).toEqual({ ok: false, reason: "group-missing" }); + }); +});