import { beforeEach, describe, expect, mock, test } from "bun:test"; import { Database } from "bun:sqlite"; import { applyCombinedSchema } from "./test-schema.test-helper"; mock.module("cloudflare:workers", () => ({ env: { PUBLIC_URL: "https://forge.example.com" } })); mock.module("@tanstack/react-start/server", () => ({ getRequestHeader: () => undefined })); const db = new Database(":memory:"); applyCombinedSchema(db); mock.module("./db", () => ({ first: async (sql: string, ...params: unknown[]) => db.query(sql).get(...(params as never[])) ?? null, all: async (sql: string, ...params: unknown[]) => db.query(sql).all(...(params as never[])), run: async (sql: string, ...params: unknown[]) => { const result = db.query(sql).run(...(params as never[])); return { meta: { changes: result.changes } }; }, batch: async (statements: { sql: string; params: unknown[] }[]) => { for (const statement of statements) db.query(statement.sql).run(...(statement.params as never[])); return []; }, stmt: (sql: string, ...params: unknown[]) => ({ sql, params }), placeholders: (count: number) => Array(count).fill("?").join(", "), chunks: (items: T[]) => [items], MAX_IN_LIST: 90, })); const invalidated: string[] = []; mock.module("./user-inbox", () => ({ invalidateUnread: async (ids: Iterable) => { invalidated.push(...ids); }, })); const counterDeltas: { repoId: string; key: string; delta: number }[] = []; mock.module("./counters", () => ({ addCounter: async (_kind: string, repoId: string, key: string, delta: number[]) => { counterDeltas.push({ repoId, key, delta: delta[0] }); return [0, 0, 0]; }, })); const { sharedScopes } = await import("./test-shared-scopes"); mock.module("./scoped-db", () => sharedScopes(db)); const { purgeFollowerIfRevoked, purgeInaccessibleFollowers, purgeOrgFollowers, revokeDeployTokensCreatedBy } = await import("./access-revocation"); const { inboxReadableSql, repoPermission } = await import("./repos"); function seed(): void { db.exec("PRAGMA foreign_keys = ON"); db.exec(` DELETE FROM stars; DELETE FROM watches; DELETE FROM thread_subscriptions; DELETE FROM issues; DELETE FROM collaborators; DELETE FROM org_members; DELETE FROM repos; DELETE FROM users; DELETE FROM accounts; INSERT INTO accounts (id, name, kind, created_at) VALUES ('org', 'acme', 'org', 0), ('owner', 'owner', 'user', 0), ('member', 'member', 'user', 0), ('collab', 'collab', 'user', 0), ('stranger', 'stranger', 'user', 0), ('root', 'root', 'user', 0); INSERT INTO users (account_id, email, is_admin) VALUES ('owner', 'o@x', 0), ('member', 'm@x', 0), ('collab', 'c@x', 0), ('stranger', 's@x', 0), ('root', 'r@x', 1); INSERT INTO repos (id, owner_id, name, visibility, default_branch, artifacts_name, artifacts_remote, created_at, updated_at) VALUES ('repo', 'org', 'app', 'public', 'main', 'art', 'remote', 0, 0); INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('org', 'owner', 'owner', 0), ('org', 'member', 'member', 0); INSERT INTO collaborators (repo_id, user_id, permission, created_at) VALUES ('repo', 'collab', 'read', 0); INSERT INTO issues (id, repo_id, number, title, body, state, author_id, is_pull, created_at, updated_at) VALUES ('issue', 'repo', 1, 't', '', 'open', 'owner', 0, 0, 0); `); for (const user of ["owner", "member", "collab", "stranger", "root"]) { db.exec(`INSERT INTO stars VALUES ('${user}', 'repo', 0); INSERT INTO watches (user_id, repo_id, created_at) VALUES ('${user}', 'repo', 0); INSERT INTO thread_subscriptions VALUES ('${user}', 'issue', 1, 0); INSERT INTO issue_assignees (issue_id, user_id) VALUES ('issue', '${user}'); INSERT INTO review_requests (issue_id, user_id, created_at) VALUES ('issue', '${user}', 0); INSERT INTO notifications (id, user_id, repo_id, reason, title, url, created_at) VALUES ('n-${user}', '${user}', 'repo', 'x', 't', '/', 0);`); } } function followers(): string[] { return (db.query("SELECT user_id FROM stars ORDER BY user_id").all() as { user_id: string }[]).map( (row) => row.user_id, ); } function watchers(): string[] { return (db.query("SELECT user_id FROM watches ORDER BY user_id").all() as { user_id: string }[]).map( (row) => row.user_id, ); } function subscribers(): string[] { return (db.query("SELECT user_id FROM thread_subscriptions ORDER BY user_id").all() as { user_id: string }[]).map( (row) => row.user_id, ); } function column(table: string): string[] { return ( db.query(`SELECT user_id FROM ${table} ORDER BY user_id`).all() as { user_id: string; }[] ).map((row) => row.user_id); } beforeEach(() => { invalidated.length = 0; seed(); }); describe("access revocation (AZ-A6)", () => { test("public -> private drops everyone without access and keeps owner, members, collaborators and admins", async () => { db.exec("UPDATE repos SET visibility = 'private'"); await purgeInaccessibleFollowers("repo"); expect(followers()).toEqual(["collab", "member", "owner", "root"]); expect(watchers()).toEqual(["collab", "member", "owner", "root"]); expect(subscribers()).toEqual(["collab", "member", "owner", "root"]); }); test("purged stars move the repo's star counter by the number removed", async () => { counterDeltas.length = 0; const before = followers().length; db.exec("UPDATE repos SET visibility = 'private'"); await purgeInaccessibleFollowers("repo"); expect(counterDeltas).toEqual([{ repoId: "repo", key: "stars", delta: followers().length - before }]); }); test("assignments and review requests go with read access; notifications are left for reads to prune (authz-P6/P7)", async () => { db.exec("UPDATE repos SET visibility = 'private'"); await purgeInaccessibleFollowers("repo"); for (const table of ["issue_assignees", "review_requests"]) { expect(column(table)).toEqual(["collab", "member", "owner", "root"]); } // Going private never walks every account; the stranger's notification is hidden and pruned on read. expect(column("notifications")).toContain("stranger"); expect(invalidated).toEqual([]); }); test("nothing is removed while the repository is public", async () => { await purgeInaccessibleFollowers("repo"); expect(followers()).toHaveLength(5); }); test("collaborator removal on a private repo purges only that user", async () => { db.exec("UPDATE repos SET visibility = 'private'; DELETE FROM collaborators WHERE user_id = 'collab'"); await purgeFollowerIfRevoked("repo", "collab"); expect(followers()).toEqual(["member", "owner", "root", "stranger"]); }); test("org membership removal purges the user across the org's private repos", async () => { db.exec("UPDATE repos SET visibility = 'private'; DELETE FROM org_members WHERE user_id = 'member'"); await purgeOrgFollowers("org", "member"); expect(followers()).toEqual(["collab", "owner", "root", "stranger"]); }); test("an org default permission of none revokes plain members (AZ-A8)", async () => { db.exec( "UPDATE repos SET visibility = 'private'; UPDATE accounts SET org_default_permission = 'none' WHERE id = 'org'", ); await purgeOrgFollowers("org", null); expect(followers()).toEqual(["collab", "owner", "root"]); }); }); describe("inbox reads filter by current access (authz-P7)", () => { test("unpurged notifications on a repository the user lost are hidden, account-level ones stay", () => { db.exec(`UPDATE repos SET visibility = 'private'; INSERT INTO notifications (id, user_id, repo_id, reason, title, url, created_at) VALUES ('acct', 'stranger', NULL, 'x', 't', '/', 0)`); const visible = (user: string) => ( db .query(`SELECT id FROM notifications n WHERE user_id = ? AND ${inboxReadableSql("n")} ORDER BY id`) .all(user) as { id: string; }[] ).map((row) => row.id); expect(visible("stranger")).toEqual(["acct"]); expect(visible("member")).toEqual(["n-member"]); expect(visible("root")).toEqual(["n-root"]); }); }); describe("repoPermission honours the org default permission (AZ-A8)", () => { const repo = () => db.query("SELECT r.*, a.name AS owner_name FROM repos r JOIN accounts a ON a.id = r.owner_id").get() as never; const member = { id: "member", name: "member", displayName: null, email: "m@x", isAdmin: false, emailVerified: true }; test("members get read by default, never write", async () => { db.exec("UPDATE repos SET visibility = 'private'"); expect(await repoPermission(repo(), member)).toBe("read"); }); test("owners can raise members to write or drop them to none", async () => { db.exec( "UPDATE repos SET visibility = 'private'; UPDATE accounts SET org_default_permission = 'write' WHERE id = 'org'", ); expect(await repoPermission(repo(), member)).toBe("write"); db.exec("UPDATE accounts SET org_default_permission = 'none' WHERE id = 'org'"); expect(await repoPermission(repo(), member)).toBe("none"); expect(await repoPermission(repo(), { ...member, id: "owner", name: "owner" })).toBe("admin"); }); }); describe("deploy tokens follow admin standing (AUTH-P9)", () => { function deploy(id: string, createdBy: string): void { db.query( `INSERT INTO deploy_tokens (id, repo_id, name, token_hash, token_prefix, permission, created_by, created_at) VALUES (?, 'repo', ?, ?, 'p', 'write', ?, 0)`, ).run(id, id, `hash-${id}`, createdBy); db.query("INSERT OR REPLACE INTO scope_locators (kind, key, scope_id) VALUES ('deploy_token', ?, 'repo')").run( `hash-${id}`, ); } const tokens = () => (db.query("SELECT id FROM deploy_tokens ORDER BY id").all() as { id: string }[]).map((r) => r.id); const locators = () => (db.query("SELECT key FROM scope_locators WHERE kind = 'deploy_token'").all() as never[]).length; test("a collaborator who lost admin loses the deploy tokens they created, and their locators", async () => { // The membership change happened before (the D1 collaborators triggers are gone with the tables). deploy("by-collab", "collab"); deploy("by-owner", "owner"); await revokeDeployTokensCreatedBy("collab", ["repo"]); expect(tokens()).toEqual(["by-owner"]); expect(locators()).toBe(1); }); test("an org owner keeps their tokens; a demoted one loses them unless admin another way", async () => { deploy("by-owner", "owner"); await purgeOrgFollowers("org", "owner"); expect(tokens()).toEqual(["by-owner"]); db.exec( "INSERT INTO collaborators (repo_id, user_id, permission, created_at) VALUES ('repo', 'owner', 'admin', 0)", ); db.exec("UPDATE org_members SET role = 'member' WHERE user_id = 'owner'"); await revokeDeployTokensCreatedBy("owner", ["repo"]); expect(tokens()).toEqual(["by-owner"]); db.exec("DELETE FROM collaborators WHERE user_id = 'owner'"); await purgeFollowerIfRevoked("repo", "owner"); expect(tokens()).toEqual([]); }); });