mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-23 00:21:28 +00:00
test: cover member roles, invite lifecycle and audit log
This commit is contained in:
parent
9b8533cabc
commit
252f6f181a
3 changed files with 358 additions and 0 deletions
133
src/__tests__/audit.test.ts
Normal file
133
src/__tests__/audit.test.ts
Normal file
|
|
@ -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<Record<string, unknown>> = [];
|
||||||
|
let id = 1;
|
||||||
|
|
||||||
|
function queryAll(sql: string, args: unknown[]): Array<Record<string, unknown>> {
|
||||||
|
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<string, unknown> = { 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<Record<string, unknown>> }> => ({
|
||||||
|
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<unknown> } } => ({
|
||||||
|
bind: () => ({
|
||||||
|
run: async (): Promise<never> => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as unknown as D1Database;
|
||||||
|
await expect(recordAudit(broken, { ts: 1, action: "x" })).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
118
src/__tests__/groups.test.ts
Normal file
118
src/__tests__/groups.test.ts
Normal file
|
|
@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
107
src/__tests__/invites.test.ts
Normal file
107
src/__tests__/invites.test.ts
Normal file
|
|
@ -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<string, { value: string; expiration?: number }>();
|
||||||
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue