import { beforeEach, describe, expect, mock, test } from "bun:test"; import { Database } from "bun:sqlite"; import { applyCombinedSchema } from "./test-schema.test-helper"; const dropped: string[] = []; const sent: { to: string; text?: string; html?: string }[] = []; class StubEntrypoint {} mock.module("cloudflare:workers", () => ({ env: { PUBLIC_URL: "http://localhost:8787", SECRETS_KEY: "test-key", RATE_LIMITS_DISABLED: "true", EMAIL_FROM: "gitfor ", EVENTS: { send: async (message: { to: string; text?: string; html?: string }) => { sent.push(message); }, }, MCP_HUB: { idFromName: (name: string) => name, get: () => ({ invalidateSubscribers: async (repoId: string) => { dropped.push(repoId); }, }), }, }, waitUntil: () => undefined, WorkflowEntrypoint: StubEntrypoint, DurableObject: StubEntrypoint, WorkerEntrypoint: StubEntrypoint, })); mock.module("@tanstack/react-start/server", () => ({ getRequestHeader: () => undefined, getCookie: () => undefined, setCookie: () => undefined, deleteCookie: () => 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, run: async () => db.query(sql).run(...(params as never[])), }), 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 { completePasswordReset } = await import("./account-security"); const { storeAuthToken } = await import("./auth-tokens"); const { hashPassword } = await import("./crypto"); function seedAccounts(): void { db.exec(` DELETE FROM admin_audit_log; DELETE FROM org_invitations; DELETE FROM user_emails; DELETE FROM integrations; DELETE FROM webhooks; DELETE FROM agents; DELETE FROM oauth_codes; DELETE FROM oauth_refresh_tokens; DELETE FROM signing_keys; DELETE FROM auth_tokens; DELETE FROM access_tokens; DELETE FROM runners; DELETE FROM runner_registration_tokens; DELETE FROM ai_endpoints; 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 ('verified', 'verified', 'user', 0), ('github', 'github', 'user', 0), ('admin', 'admin', 'user', 0), ('unverified', 'unverified', 'user', 0), ('acme', 'acme', 'org', 0); INSERT INTO users (account_id, email, email_verified_at, github_id, is_admin) VALUES ('verified', 'v@x.dev', 1, NULL, 0), ('github', 'g@x.dev', NULL, 'gh-1', 0), ('admin', 'a@x.dev', NULL, NULL, 1), ('unverified', 'u@x.dev', NULL, NULL, 0); INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('acme', 'verified', 'owner', 0); `); for (const person of ["verified", "unverified"]) { db.query( `INSERT INTO repos (id, owner_id, name, artifacts_name, artifacts_remote, visibility, created_at, updated_at) VALUES (?, ?, 'app', ?, 'remote', 'public', 0, 0)`, ).run(`repo-${person}`, person, `art-${person}`); db.query( `INSERT INTO access_tokens (id, user_id, name, token_hash, token_prefix, scopes, permissions, all_repos, created_at) VALUES (?, ?, 'cli', ?, 'p', '', '{}', 1, 0)`, ).run(`pat-${person}`, person, `hash-${person}`); } } describe("password-reset handover (AUTH-A2)", () => { async function handover(): Promise { const hash = await hashPassword("squatter-password"); db.query("UPDATE users SET password_hash = ? WHERE account_id = 'unverified'").run(hash); await storeAuthToken( "password-reset", "reset-token", "unverified", JSON.stringify({ passwordHash: hash, viaPrimary: true }), 3600, ); await completePasswordReset("reset-token", "inbox-owner-password"); } function count(sql: string): number { return (db.query(sql).get() as { n: number }).n; } beforeEach(async () => { dropped.length = 0; sent.length = 0; seedAccounts(); db.exec(` INSERT INTO runner_registration_tokens (token_hash, repo_id, owner_id, created_by, expires_at, created_at) VALUES ('rt-created', 'repo-verified', NULL, 'unverified', 9e15, 0), ('rt-owner', NULL, 'unverified', 'verified', 9e15, 0), ('rt-repo', 'repo-unverified', NULL, 'verified', 9e15, 0), ('rt-other', 'repo-verified', NULL, 'verified', 9e15, 0); INSERT INTO runners (id, repo_id, owner_id, name, labels, token_hash, created_at) VALUES ('run-owner', NULL, 'unverified', 'r', '[]', 'h1', 0), ('run-repo', 'repo-unverified', NULL, 'r', '[]', 'h2', 0), ('run-other', 'repo-verified', NULL, 'r', '[]', 'h3', 0); INSERT INTO ai_endpoints (scope, owner_id, kind, model, key_ciphertext, key_iv, updated_at) VALUES ('account', 'unverified', 'provider', 'm', 'c', 'i', 0), ('repo', 'repo-unverified', 'provider', 'm', 'c', 'i', 0), ('account', 'verified', 'provider', 'm', 'c', 'i', 0), ('repo', 'repo-verified', 'provider', 'm', 'c', 'i', 0); INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('acme', 'unverified', 'member', 0); INSERT INTO collaborators (repo_id, user_id, permission, created_at) VALUES ('repo-verified', 'unverified', 'write', 0), ('repo-verified', 'github', 'write', 0); INSERT INTO oauth_refresh_tokens (token_hash, client_id, user_id, access_token_id, resource, grant_json, expires_at, created_at) VALUES ('r-u', 'c', 'unverified', 'pat-unverified', 'res', '{}', 9e15, 0), ('r-v', 'c', 'verified', 'pat-verified', 'res', '{}', 9e15, 0); INSERT INTO oauth_codes (code_hash, client_id, user_id, redirect_uri, code_challenge, resource, grant_json, expires_at) VALUES ('c-u', 'c', 'unverified', 'r', 'x', 'res', '{}', 9e15), ('c-v', 'c', 'verified', 'r', 'x', 'res', '{}', 9e15); INSERT INTO agents (id, owner_id, name, created_at) VALUES ('ag-u', 'unverified', 'bot', 0), ('ag-v', 'verified', 'bot', 0); INSERT INTO signing_keys (id, user_id, kind, title, fingerprint, public_key, created_at) VALUES ('k-u', 'unverified', 'ssh', 't', 'f1', 'p', 0), ('k-v', 'verified', 'ssh', 't', 'f2', 'p', 0); INSERT INTO webhooks (id, repo_id, url, events, created_at) VALUES ('w-u', 'repo-unverified', 'https://e', '[]', 0), ('w-v', 'repo-verified', 'https://e', '[]', 0); INSERT INTO integrations (id, scope_type, scope_id, kind, name, created_by, created_at, updated_at) VALUES ('in-u', 'org', 'acme', 'webhook', 'n', 'unverified', 0, 0), ('in-v', 'repo', 'repo-verified', 'webhook', 'n', 'verified', 0, 0); INSERT INTO user_emails (user_id, email, verified_at, created_at) VALUES ('unverified', 'other@x.dev', 5, 0), ('unverified', 'pending@x.dev', NULL, 0), ('verified', 'v2@x.dev', 5, 0); INSERT INTO org_invitations (id, org_id, user_id, role, invited_by, created_at, expires_at) VALUES ('inv-to', 'acme', 'unverified', 'member', 'verified', 0, 9e15), ('inv-by', 'acme', 'github', 'member', 'unverified', 0, 9e15), ('inv-other', 'acme', 'admin', 'member', 'verified', 0, 9e15); `); await handover(); }); test("OAuth/MCP grants, refresh tokens and codes are removed", () => { expect(count("SELECT COUNT(*) AS n FROM access_tokens WHERE user_id = 'unverified'")).toBe(0); expect(count("SELECT COUNT(*) AS n FROM oauth_refresh_tokens WHERE user_id = 'unverified'")).toBe(0); expect(count("SELECT COUNT(*) AS n FROM oauth_codes WHERE user_id = 'unverified'")).toBe(0); expect(count("SELECT COUNT(*) AS n FROM oauth_refresh_tokens WHERE user_id = 'verified'")).toBe(1); expect(count("SELECT COUNT(*) AS n FROM oauth_codes WHERE user_id = 'verified'")).toBe(1); }); test("agents and their tokens are removed", () => { expect(count("SELECT COUNT(*) AS n FROM agents WHERE owner_id = 'unverified'")).toBe(0); expect(count("SELECT COUNT(*) AS n FROM agents WHERE owner_id = 'verified'")).toBe(1); }); test("SSH and signing keys are removed", () => { expect(count("SELECT COUNT(*) AS n FROM signing_keys WHERE user_id = 'unverified'")).toBe(0); expect(count("SELECT COUNT(*) AS n FROM signing_keys WHERE user_id = 'verified'")).toBe(1); }); test("webhooks and integrations the creator set up are removed", () => { expect(count("SELECT COUNT(*) AS n FROM webhooks WHERE id = 'w-u'")).toBe(0); expect(count("SELECT COUNT(*) AS n FROM integrations WHERE id = 'in-u'")).toBe(0); expect(count("SELECT COUNT(*) AS n FROM webhooks WHERE id = 'w-v'")).toBe(1); expect(count("SELECT COUNT(*) AS n FROM integrations WHERE id = 'in-v'")).toBe(1); }); test("verified and unverified secondary emails are removed", () => { expect(count("SELECT COUNT(*) AS n FROM user_emails WHERE user_id = 'unverified'")).toBe(0); expect(count("SELECT COUNT(*) AS n FROM user_emails WHERE user_id = 'verified'")).toBe(1); }); test("pending org invitations to and from the account are removed", () => { expect(count("SELECT COUNT(*) AS n FROM org_invitations WHERE id IN ('inv-to', 'inv-by')")).toBe(0); expect(count("SELECT COUNT(*) AS n FROM org_invitations WHERE id = 'inv-other'")).toBe(1); }); test("runner registration tokens it created, owns or that target its repositories are removed", () => { expect(count("SELECT COUNT(*) AS n FROM runner_registration_tokens WHERE token_hash != 'rt-other'")).toBe(0); expect(count("SELECT COUNT(*) AS n FROM runner_registration_tokens WHERE token_hash = 'rt-other'")).toBe(1); }); test("self-hosted runners it owns or that serve its repositories are removed", () => { expect(count("SELECT COUNT(*) AS n FROM runners WHERE id IN ('run-owner', 'run-repo')")).toBe(0); expect(count("SELECT COUNT(*) AS n FROM runners WHERE id = 'run-other'")).toBe(1); }); test("its AI provider keys (account_ai_keys, now ai_endpoints) and its repositories' keys are removed", () => { expect(count("SELECT COUNT(*) AS n FROM ai_endpoints WHERE owner_id IN ('unverified', 'repo-unverified')")).toBe(0); expect(count("SELECT COUNT(*) AS n FROM ai_endpoints WHERE owner_id IN ('verified', 'repo-verified')")).toBe(2); }); test("its organization memberships are removed", () => { expect(count("SELECT COUNT(*) AS n FROM org_members WHERE user_id = 'unverified'")).toBe(0); expect(count("SELECT COUNT(*) AS n FROM org_members WHERE user_id = 'verified'")).toBe(1); }); test("its collaborator access to other people's repositories is removed", () => { expect(count("SELECT COUNT(*) AS n FROM collaborators WHERE user_id = 'unverified'")).toBe(0); expect(count("SELECT COUNT(*) AS n FROM collaborators WHERE user_id = 'github'")).toBe(1); }); test("the security notice names what was removed", () => { const notice = JSON.stringify(sent); for (const item of [ "runner registration tokens", "AI provider keys", "organization memberships", "collaborator access", ]) { expect(notice).toContain(item); } }); test("MCP sessions on its repositories are dropped, the repositories stay and the handover is audited", () => { expect(dropped).toEqual(["repo-unverified"]); expect(count("SELECT COUNT(*) AS n FROM repos WHERE owner_id = 'unverified'")).toBe(1); expect( count("SELECT COUNT(*) AS n FROM admin_audit_log WHERE action = 'account.handover' AND target_id = 'unverified'"), ).toBe(1); expect( count("SELECT COUNT(*) AS n FROM users WHERE account_id = 'unverified' AND email_verified_at IS NOT NULL"), ).toBe(1); }); });