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", SECRETS_KEY: "test-key" }, waitUntil: () => undefined, })); mock.module("@tanstack/react-start/server", () => ({ getRequestHeader: () => undefined, getCookie: () => undefined, setCookie: () => undefined, deleteCookie: () => undefined, })); const notices: string[] = []; mock.module("./account-security", () => ({ notifySecurityEvent: async (_userId: string, event: string) => { notices.push(event); }, })); 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 { sharedScopes } = await import("./test-shared-scopes"); mock.module("./scoped-db", () => sharedScopes(db)); const { mintFineGrainedToken, tokenLineage } = await import("./mcp/grants"); const { disableRepoConfigOnTransfer, repoOwnershipChangeStatements } = await import("./integrations/ownership"); const { revokeAccessTokens } = await import("./access-token-store"); const { setCollaborator } = await import("./collaborators"); const { revokeDeployTokensCreatedBy } = await import("./access-revocation"); const { clearTotpFailures, recordTotpFailure, verifyUserTotp, TOTP_LOCKOUT_THRESHOLD } = await import("./two-factor"); function seed(): void { db.exec("PRAGMA foreign_keys = ON"); db.exec(` DELETE FROM deploy_tokens; DELETE FROM mcp_token_agents; DELETE FROM access_tokens; 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), ('alice', 'alice', 'user', 0), ('bob', 'bob', 'user', 0), ('carol', 'carol', 'user', 0); INSERT INTO users (account_id, email, is_admin) VALUES ('alice', 'a@x', 0), ('bob', 'b@x', 0), ('carol', 'c@x', 0); INSERT INTO repos (id, owner_id, name, visibility, default_branch, artifacts_name, artifacts_remote, created_at, updated_at) VALUES ('repo', 'org', 'app', 'private', 'main', 'art', 'remote', 0, 0), ('own', 'alice', 'mine', 'private', 'main', 'art2', 'remote2', 0, 0); INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('org', 'alice', 'owner', 0), ('org', 'bob', 'member', 0); INSERT INTO collaborators (repo_id, user_id, permission, created_at) VALUES ('repo', 'carol', 'admin', 0); `); } function insertToken(id: string, parent: string | null): void { db.query( `INSERT INTO access_tokens (id, user_id, name, token_hash, token_prefix, scopes, permissions, all_repos, created_at, parent_token_id) VALUES (?, 'alice', ?, ?, 'p', 'fine-grained', '{}', 1, 0, ?)`, ).run(id, id, `hash-${id}`, parent); db.query("INSERT OR REPLACE INTO scope_locators (kind, key, scope_id) VALUES ('access_token_id', ?, 'alice')").run( id, ); } function insertDeploy(id: string, repoId: string, createdBy: string, parent: string | null = null): void { db.query( `INSERT INTO deploy_tokens (id, repo_id, name, token_hash, token_prefix, permission, created_by, created_at, parent_token_id) VALUES (?, ?, ?, ?, 'p', 'write', ?, 0, ?)`, ).run(id, repoId, id, `dhash-${id}`, createdBy, parent); } function ids(table: string): string[] { return (db.query(`SELECT id FROM ${table} ORDER BY id`).all() as { id: string }[]).map((row) => row.id); } beforeEach(() => { seed(); notices.length = 0; }); describe("token lineage (AUTH-P2)", () => { test("delegated tokens record their parent on access_tokens and report it in tokenLineage", async () => { insertToken("root", null); const minted = await mintFineGrainedToken({ userId: "alice", name: "child", scopes: "oauth:delegated", grant: { permissions: { contents: "read" }, allRepos: false, repoIds: [], agent: "bot" }, ttlMs: 1000, clientId: null, parentTokenId: "root", notAfter: null, kind: "delegated", audience: null, }); const row = db.query("SELECT parent_token_id FROM access_tokens WHERE id = ?").get(minted.id) as { parent_token_id: string; }; expect(row.parent_token_id).toBe("root"); expect((await tokenLineage(minted.id))?.parent_token_id).toBe("root"); }); test("a REST-minted child is reported as delegated so it cannot delegate further", async () => { insertToken("root", null); insertToken("rest-child", "root"); expect((await tokenLineage("rest-child"))?.parent_token_id).toBe("root"); expect((await tokenLineage("root"))?.parent_token_id).toBeNull(); }); test("revoking a token deletes every descendant and their deploy tokens", async () => { insertToken("root", null); insertToken("child", "root"); insertToken("grandchild", "child"); insertToken("mcp-great", null); db.exec( "INSERT INTO mcp_token_agents (token_id, agent_name, authorized_by, parent_token_id, created_at, kind) VALUES ('mcp-great', 'bot', 'alice', 'grandchild', 0, 'delegated')", ); insertToken("unrelated", null); insertDeploy("d-root", "repo", "alice", "root"); insertDeploy("d-grand", "repo", "alice", "grandchild"); insertDeploy("d-other", "repo", "alice", "unrelated"); await revokeAccessTokens("alice", ["root"]); expect(ids("access_tokens")).toEqual(["unrelated"]); expect(ids("deploy_tokens")).toEqual(["d-other"]); }); }); describe("deploy tokens follow admin standing", () => { test("a repository transfer revokes its deploy tokens (AUTH-P1)", async () => { insertDeploy("d1", "repo", "alice"); insertDeploy("d2", "own", "alice"); db.exec(`DELETE FROM runners; DELETE FROM runner_registration_tokens; INSERT INTO runners (id, repo_id, owner_id, name, labels, token_hash, created_at) VALUES ('r-repo', 'repo', NULL, 'r1', '[]', 'h1', 0), ('r-own', 'own', NULL, 'r2', '[]', 'h2', 0); INSERT INTO runner_registration_tokens (token_hash, repo_id, owner_id, created_by, expires_at, created_at) VALUES ('reg-repo', 'repo', NULL, 'alice', 0, 0);`); for (const statement of repoOwnershipChangeStatements("repo", "alice") as unknown as { sql: string; params: unknown[]; }[]) { db.query(statement.sql).run(...(statement.params as never[])); } await disableRepoConfigOnTransfer("repo", "org"); expect(ids("deploy_tokens")).toEqual(["d2"]); expect(ids("runners")).toEqual(["r-own"]); expect(db.query("SELECT COUNT(*) AS n FROM runner_registration_tokens").get()).toEqual({ n: 0 }); }); test("downgrading an admin collaborator revokes the deploy tokens they created (AUTH-P9)", async () => { insertDeploy("by-carol", "repo", "carol"); insertDeploy("by-alice", "repo", "alice"); await setCollaborator({ id: "repo" }, "carol", "write"); expect(ids("deploy_tokens")).toEqual(["by-alice"]); }); test("demoting an org owner revokes their deploy tokens on org repos but not on their own", async () => { insertDeploy("org-repo", "repo", "alice"); insertDeploy("own-repo", "own", "alice"); db.exec("UPDATE org_members SET role = 'member' WHERE user_id = 'alice'"); await revokeDeployTokensCreatedBy("alice", ["repo"]); expect(ids("deploy_tokens")).toEqual(["own-repo"]); }); test("an org owner who is also an admin collaborator keeps their deploy token", async () => { db.exec( "INSERT INTO collaborators (repo_id, user_id, permission, created_at) VALUES ('repo', 'alice', 'admin', 0)", ); insertDeploy("org-repo", "repo", "alice"); db.exec("DELETE FROM org_members WHERE user_id = 'alice'"); await revokeDeployTokensCreatedBy("alice", ["repo"]); expect(ids("deploy_tokens")).toEqual(["org-repo"]); }); }); describe("TOTP lockout (AUTH-P4)", () => { test("the threshold locks TOTP, later failures double the lock, and success clears it", async () => { const lockedFor = () => { const row = db.query("SELECT totp_failures, totp_locked_until FROM users WHERE account_id = 'alice'").get() as { totp_failures: number; totp_locked_until: number | null; }; return { failures: row.totp_failures, minutes: row.totp_locked_until === null ? 0 : Math.round((row.totp_locked_until - Date.now()) / 60000), }; }; for (let attempt = 1; attempt < TOTP_LOCKOUT_THRESHOLD; attempt++) await recordTotpFailure("alice"); expect(lockedFor()).toEqual({ failures: TOTP_LOCKOUT_THRESHOLD - 1, minutes: 0 }); expect(notices).toEqual([]); await recordTotpFailure("alice"); expect(lockedFor()).toEqual({ failures: TOTP_LOCKOUT_THRESHOLD, minutes: 5 }); await recordTotpFailure("alice"); expect(lockedFor().minutes).toBe(10); const failuresPastMaximum = 40; for (let attempt = 0; attempt < failuresPastMaximum; attempt++) await recordTotpFailure("alice"); expect(lockedFor().minutes).toBe(24 * 60); expect(notices.length).toBeGreaterThan(0); await clearTotpFailures("alice"); expect(lockedFor()).toEqual({ failures: 0, minutes: 0 }); }); test("a locked account refuses codes before checking them", async () => { db.query( "UPDATE users SET totp_secret = 'x', totp_iv = 'y', totp_enabled_at = 1, totp_locked_until = ? WHERE account_id = 'alice'", ).run(Date.now() + 60_000); await expect(verifyUserTotp("alice", "123456")).rejects.toThrow("Too many wrong codes"); }); });