import { beforeEach, describe, expect, mock, test } from "bun:test"; import { Database } from "bun:sqlite"; import { applyCombinedSchema } from "./test-schema.test-helper"; class StubEntrypoint {} mock.module("cloudflare:workers", () => ({ env: { PUBLIC_URL: "http://localhost:8787", SECRETS_KEY: "test-key", RATE_LIMITS_DISABLED: "true" }, 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 { mintFineGrainedToken } = await import("./mcp/grants"); const { createDeployToken, createFineGrainedToken } = await import("./token-write"); const { handleOAuthRoutes } = await import("./mcp/oauth"); const { sha256Hex } = await import("./crypto"); const { assertOwnerNotDisabled } = await import("./repos"); const { deleteOrganization } = await import("./org-delete"); const { toInfo } = await import("./security/signatures"); const alice = { id: "alice", name: "alice", displayName: null, email: "a@x", isAdmin: false, emailVerified: true }; const GRANT = JSON.stringify({ permissions: {}, allRepos: true, repoIds: [], repoNames: [], agent: "a" }); function seed(): void { db.exec(` DELETE FROM oauth_refresh_tokens; DELETE FROM oauth_codes; DELETE FROM access_tokens; DELETE FROM org_members; DELETE FROM repos; DELETE FROM users; DELETE FROM accounts; INSERT INTO accounts (id, name, kind, created_at, disabled_at, disabled_reason) VALUES ('alice', 'alice', 'user', 0, 5, 'admin'), ('org', 'acme', 'org', 0, 5, 'payment_dispute'), ('bob', 'bob', 'user', 0, NULL, NULL); INSERT INTO users (account_id, email, is_admin) VALUES ('alice', 'a@x', 0), ('bob', 'b@x', 0); INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('org', 'bob', 'owner', 0); INSERT INTO access_tokens (id, user_id, name, token_hash, token_prefix, scopes, permissions, all_repos, created_at) VALUES ('t1', 'alice', 'MCP', 'old', 'p', 'oauth:c1', '{}', 1, 0); `); } async function tokenRequest(fields: Record): Promise<{ status: number; body: { error?: string } }> { const response = await handleOAuthRoutes( new Request("http://localhost:8787/oauth/token", { method: "POST", body: new URLSearchParams(fields) }), new URL("http://localhost:8787/oauth/token"), ); if (!response) throw new Error("no response"); return { status: response.status, body: (await response.json()) as { error?: string } }; } async function rejection(promise: Promise): Promise { try { await promise; return "ok"; } catch (error) { return (error as { status: number }).status; } } beforeEach(seed); describe("disabled accounts can't mint credentials", () => { test("personal, fine-grained, OAuth and deploy token mints are refused", async () => { expect( await rejection( mintFineGrainedToken({ userId: "alice", name: "x", scopes: "oauth:c1", grant: { permissions: {}, allRepos: true, repoIds: [], agent: "a" }, ttlMs: 1000, clientId: "c1", kind: "oauth", audience: null, }), ), ).toBe(403); expect( await rejection( createFineGrainedToken(alice, { name: "x", permissions: {}, allRepos: true, repos: [], expiresInDays: 1 }), ), ).toBe(403); expect( await rejection(createDeployToken({ id: "r" }, alice, { name: "x", permission: "read", expiresInDays: 1 })), ).toBe(403); }); async function exchange(userId: string) { const verifier = "v".repeat(43); const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))); const challenge = btoa(String.fromCharCode(...digest)) .replaceAll("+", "-") .replaceAll("/", "_") .replace(/=+$/, ""); db.query( `INSERT INTO oauth_codes (code_hash, client_id, user_id, redirect_uri, code_challenge, resource, grant_json, expires_at) VALUES (?, 'c1', ?, 'http://localhost/cb', ?, 'http://localhost:8787/mcp', ?, ?)`, ).run(await sha256Hex("code_x"), userId, challenge, GRANT, Date.now() + 60_000); db.query("INSERT OR REPLACE INTO scope_locators (kind, key, scope_id) VALUES ('oauth_code', ?, ?)").run( await sha256Hex("code_x"), userId, ); return tokenRequest({ grant_type: "authorization_code", code: "code_x", code_verifier: verifier, client_id: "c1", redirect_uri: "http://localhost/cb", }); } test("an authorization code issued before the disable can't be exchanged", async () => { expect((await exchange("bob")).status).toBe(200); db.exec("DELETE FROM access_tokens WHERE user_id = 'bob'"); const result = await exchange("alice"); expect(result.body.error).toBe("invalid_grant"); expect(db.query("SELECT COUNT(*) AS n FROM access_tokens").get()).toEqual({ n: 1 }); }); test("a refresh is refused without consuming the token or revoking the grant", async () => { db.query( `INSERT INTO oauth_refresh_tokens (token_hash, client_id, user_id, access_token_id, resource, grant_json, expires_at, created_at) VALUES (?, 'c1', 'alice', 't1', 'r', ?, ?, 0)`, ).run(await sha256Hex("refresh_x"), GRANT, Date.now() + 60_000); const result = await tokenRequest({ grant_type: "refresh_token", refresh_token: "refresh_x", client_id: "c1" }); expect(result.body.error).toBe("invalid_grant"); expect(db.query("SELECT used_at FROM oauth_refresh_tokens").get()).toEqual({ used_at: null }); expect(db.query("SELECT token_hash FROM access_tokens WHERE id = 't1'").get()).toEqual({ token_hash: "old" }); }); }); describe("disabled organizations' settings are frozen", () => { test("org owners can't change, transfer or delete a disabled account's repositories; site admins can", () => { expect(() => assertOwnerNotDisabled({ owner_disabled_at: 5 }, { ...alice, id: "bob" })).toThrow("disabled"); expect(() => assertOwnerNotDisabled({ owner_disabled_at: 5 }, { ...alice, isAdmin: true, emailVerified: true }), ).not.toThrow(); expect(() => assertOwnerNotDisabled({ owner_disabled_at: null }, alice)).not.toThrow(); }); test("an org owner can't delete a disabled organization", async () => { const bob = { ...alice, id: "bob", name: "bob" }; expect(await rejection(deleteOrganization({ org: "acme", confirm: "acme", deleteRepos: true, viewer: bob }))).toBe( 403, ); }); }); describe("signatures by disabled accounts", () => { const row = { commit_sha: "a".repeat(40), verified: 1, reason: "valid", kind: "ssh" as const, key_fingerprint: "SHA256:x", signer_name: "alice", signer_id: "alice", }; test("show as unverified while the signer is disabled and verified again once re-enabled", () => { expect(toInfo({ ...row, signer_disabled_at: 5 })).toMatchObject({ verified: false, reason: "signer_disabled" }); expect(toInfo({ ...row, signer_disabled_at: null })).toMatchObject({ verified: true, reason: "valid" }); }); });