import { beforeEach, describe, expect, mock, test } from "bun:test"; import { Database } from "bun:sqlite"; import { applyCombinedSchema } from "./test-schema.test-helper"; import type { Viewer } from "~/lib/types"; type Handler = (input: { data: Record; context: { viewer: Viewer } }) => Promise; type Builder = { middleware: () => Builder; validator: () => Builder; handler: (fn: Handler) => Handler }; function builder(): Builder { const self: Builder = { middleware: () => self, validator: () => self, handler: (fn) => fn }; return self; } const dropped: string[] = []; const testEnv: Record = { PUBLIC_URL: "http://localhost:8787", SECRETS_KEY: "test-key", RATE_LIMITS_DISABLED: "true", EMAIL_FROM: "gitfor ", EVENTS: { send: async () => undefined }, KV: { get: async () => null, put: async () => undefined, delete: async () => undefined }, MCP_HUB: { idFromName: (name: string) => name, get: () => ({ invalidateSubscribers: async (repoId: string) => { dropped.push(repoId); }, }), }, }; class StubEntrypoint {} mock.module("cloudflare:workers", () => ({ env: testEnv, waitUntil: () => undefined, WorkflowEntrypoint: StubEntrypoint, DurableObject: StubEntrypoint, WorkerEntrypoint: StubEntrypoint, })); mock.module("@tanstack/react-start/server", () => ({ getRequestHeader: () => undefined, getCookie: () => undefined, setCookie: () => undefined, deleteCookie: () => undefined, })); mock.module("@tanstack/react-start", () => ({ createServerFn: () => builder(), createMiddleware: () => builder() })); mock.module("../fns/middleware", () => ({ viewerMiddleware: {}, requireViewer: (viewer: Viewer) => viewer })); mock.module("./repo-provisioning", () => ({ describeProvisioningError: () => "", markProvisioningFailed: async () => undefined, startProvisioning: async () => undefined, RepoProvisionWorkflow: StubEntrypoint, })); 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 { loadUserById, toViewer } = await import("./auth"); const { assertMayDeleteRepo, assertVerifiedForWrites } = await import("./verified-writes"); const { limitAccountWrite, limitWrite } = await import("./limits"); const { EMAIL_UNVERIFIED, EMAIL_UNVERIFIED_MESSAGE, isEmailUnverifiedError } = await import("~/lib/email-verification"); const { sha256Hex } = await import("./crypto"); const { handleApiV1 } = await import("./api/router"); const { callTool } = await import("./mcp/tools"); const { handleGitRequest, matchGitRoute } = await import("./git/proxy"); const { createSubscriptionCheckout, createTopupCheckout, createDomainAddonCheckout } = await import("./billing/checkout"); const { reclaimUnverifiedEmail, respondToOrgInvitation } = await import("./accounts"); const { createRepo, deleteFailedRepo, deleteRepo, toggleStar } = await import("../fns/repos"); const { deleteOrganization } = await import("../fns/account"); const { setFollow } = await import("../fns/profile"); const { startProCheckout } = await import("../fns/billing"); const { updateNotificationSettings } = await import("../fns/notification-settings"); const PEOPLE = ["verified", "github", "admin", "unverified"] as const; type Person = (typeof PEOPLE)[number]; const OLD = Date.now() - 3 * 24 * 60 * 60 * 1000; function seed(): 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 stars; DELETE FROM follows; DELETE FROM activity; 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', ${OLD}), ('github', 'github', 'user', ${OLD}), ('admin', 'admin', 'user', ${OLD}), ('unverified', 'unverified', 'user', ${OLD}), ('acme', 'acme', 'org', ${OLD}); 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 PEOPLE) { 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}`); } } async function viewerOf(person: Person): Promise { const user = await loadUserById(person); if (!user) throw new Error(`no ${person}`); return toViewer(user); } async function patFor(person: Person): Promise { const token = `gitfor_pat_${person}_secret`; db.query( `INSERT OR IGNORE 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, await sha256Hex(token), JSON.stringify({ admin: "write", contents: "write", issues: "write", pulls: "write" }), ); db.query( `INSERT OR REPLACE INTO scope_locators (kind, key, scope_id) VALUES ('access_token', ?, ?), ('access_token_id', ?, ?)`, ).run(await sha256Hex(token), person, `pat-${person}`, person); return token; } /** "gate" when the email-verification gate refused, otherwise "passed" (whatever happened afterwards). */ async function gate(work: () => Promise | unknown): Promise<"gate" | "passed"> { try { await work(); return "passed"; } catch (error) { return isEmailUnverifiedError(error) ? "gate" : "passed"; } } function invoke(fn: unknown, viewer: Viewer, data: Record): Promise { return (fn as Handler)({ data, context: { viewer } }); } beforeEach(() => { testEnv.EMAIL_FROM = "gitfor "; dropped.length = 0; seed(); }); describe("assertVerifiedForWrites matrix", () => { test("verified, GitHub-linked and admin accounts pass; an unverified one is refused with a stable code", async () => { for (const person of ["verified", "github", "admin"] as const) { expect(await gate(async () => assertVerifiedForWrites(await viewerOf(person)))).toBe("passed"); } const viewer = await viewerOf("unverified"); let caught: unknown = null; try { assertVerifiedForWrites(viewer); } catch (error) { caught = error; } expect(caught).toMatchObject({ status: 403, message: EMAIL_UNVERIFIED_MESSAGE, detail: { code: EMAIL_UNVERIFIED }, }); }); test("an instance without email lets unverified accounts write", async () => { testEnv.EMAIL_FROM = undefined; expect(await gate(async () => assertVerifiedForWrites(await viewerOf("unverified")))).toBe("passed"); }); test("limitWrite gates, limitAccountWrite (self-service) does not", async () => { const viewer = await viewerOf("unverified"); expect(await gate(() => limitWrite(viewer))).toBe("gate"); expect(await gate(() => limitAccountWrite(viewer))).toBe("passed"); }); }); describe("web server functions", () => { test("repository creation is refused for an unverified viewer only", async () => { const input = { owner: "nobody-here", name: "x", description: null, visibility: "public", initReadme: false }; expect(await gate(async () => invoke(createRepo, await viewerOf("unverified"), input))).toBe("gate"); expect(await gate(async () => invoke(createRepo, await viewerOf("verified"), input))).toBe("passed"); expect(await gate(async () => invoke(createRepo, await viewerOf("github"), input))).toBe("passed"); expect(await gate(async () => invoke(createRepo, await viewerOf("admin"), input))).toBe("passed"); }); test("checkout is refused for an unverified viewer", async () => { expect(await gate(async () => invoke(startProCheckout, await viewerOf("unverified"), { org: null }))).toBe("gate"); expect(await gate(async () => invoke(startProCheckout, await viewerOf("verified"), { org: null }))).toBe("passed"); }); test("starring, following and notification settings stay open while unverified", async () => { const viewer = await viewerOf("unverified"); await invoke(toggleStar, viewer, { owner: "verified", repo: "app", star: true }).catch(() => undefined); expect(db.query("SELECT 1 FROM stars WHERE user_id = 'unverified'").get()).not.toBeNull(); await invoke(setFollow, viewer, { name: "verified", follow: true }).catch(() => undefined); expect(db.query("SELECT 1 FROM follows WHERE follower_id = 'unverified'").get()).not.toBeNull(); expect(await gate(() => invoke(updateNotificationSettings, viewer, {}))).toBe("passed"); }); test("declining an org invitation stays open; accepting one needs a verified email", async () => { const viewer = await viewerOf("unverified"); db.exec( "INSERT INTO org_invitations (id, org_id, user_id, role, created_at, expires_at) VALUES ('i1', 'acme', 'unverified', 'member', 0, 9999999999999)", ); expect(await gate(() => respondToOrgInvitation({ org: "acme", accept: true, viewer }))).toBe("gate"); expect(await gate(() => respondToOrgInvitation({ org: "acme", accept: false, viewer }))).toBe("passed"); expect(db.query("SELECT 1 FROM org_invitations WHERE id = 'i1'").get()).toBeNull(); }); }); describe("REST API", () => { async function api(person: Person, method: string, path: string, body: unknown = {}): Promise { const url = new URL(`http://localhost:8787/api/v1${path}`); const request = new Request(url, { method, headers: { authorization: `Bearer ${await patFor(person)}`, "content-type": "application/json" }, body: method === "GET" ? undefined : JSON.stringify(body), }); return handleApiV1(request, url); } test("writes need a verified email; GitHub links and admins count", async () => { const issue = { title: "hi" }; expect(await gate(() => api("unverified", "POST", "/repos/verified/app/issues", issue))).toBe("gate"); expect(await gate(() => api("unverified", "POST", "/repos", { name: "x" }))).toBe("gate"); expect(await gate(() => api("verified", "POST", "/repos/verified/app/issues", issue))).toBe("passed"); expect(await gate(() => api("github", "POST", "/repos/verified/app/issues", issue))).toBe("passed"); expect(await gate(() => api("admin", "POST", "/repos/verified/app/issues", issue))).toBe("passed"); }); test("the refusal carries the code in the JSON body clients branch on", async () => { let caught: unknown = null; try { await api("unverified", "POST", "/user/tokens", { name: "x" }); } catch (error) { caught = error; } expect(caught).toMatchObject({ status: 403, detail: { code: EMAIL_UNVERIFIED } }); }); test("reads and self-service writes stay open", async () => { expect(await gate(() => api("unverified", "GET", "/user"))).toBe("passed"); expect(await gate(() => api("unverified", "PUT", "/user/notification-settings", {}))).toBe("passed"); expect(await gate(() => api("unverified", "PUT", "/notifications", {}))).toBe("passed"); expect(await gate(() => api("unverified", "DELETE", "/user/tokens/pat-unverified"))).toBe("passed"); }); }); describe("MCP tools", () => { async function tool(person: Person, name: string, args: Record): Promise { const viewer = await viewerOf(person); const caller = { credential: { viewer, grant: null }, viewer, tokenId: `pat-${person}`, agent: { name: "test", clientId: null }, era: "legacy", sessionId: null, clientCapabilities: {}, origin: "http://localhost:8787", }; const result = (await callTool( caller as never, { name, arguments: args }, { signal: new AbortController().signal, progress: () => undefined, }, )) as { content: { text?: string }[] }; return result.content[0]?.text ?? ""; } test("write tools refuse an unverified caller; verified callers get past the gate", async () => { const args = { owner: "verified", repo: "app", title: "t", body: "" }; expect(await tool("unverified", "create_issue", args)).toBe(EMAIL_UNVERIFIED_MESSAGE); expect(await tool("verified", "create_issue", args)).not.toBe(EMAIL_UNVERIFIED_MESSAGE); expect(await tool("github", "create_issue", args)).not.toBe(EMAIL_UNVERIFIED_MESSAGE); }); test("read tools and anonymous scratch sites stay open", async () => { expect(await tool("unverified", "get_repo", { owner: "verified", repo: "app" })).not.toBe(EMAIL_UNVERIFIED_MESSAGE); expect(await tool("unverified", "create_scratch_site", { files: { "index.html": "hi" } })).not.toBe( EMAIL_UNVERIFIED_MESSAGE, ); }); }); describe("git over HTTPS", () => { async function push(person: Person, service = "git-receive-pack"): Promise<{ status: number; text: string }> { const url = new URL(`http://localhost:8787/${person}/app.git/info/refs?service=${service}`); const route = matchGitRoute(url); if (!route) throw new Error("no route"); const auth = btoa(`x:${await patFor(person)}`); const ctx = { waitUntil: () => undefined, passThroughOnException: () => undefined } as never; try { const response = await handleGitRequest( new Request(url, { headers: { authorization: `Basic ${auth}` } }), route, ctx, ); return { status: response.status, text: await response.text() }; } catch { return { status: 0, text: "" }; } } test("receive-pack refuses an unverified pusher with the verify message", async () => { const refused = await push("unverified"); expect(refused.status).toBe(403); expect(refused.text.trim()).toBe(EMAIL_UNVERIFIED_MESSAGE); }); test("verified, GitHub-linked and admin pushers, and unverified fetches, get past the gate", async () => { for (const person of ["verified", "github", "admin"] as const) { expect((await push(person)).text.trim()).not.toBe(EMAIL_UNVERIFIED_MESSAGE); } expect((await push("unverified", "git-upload-pack")).text.trim()).not.toBe(EMAIL_UNVERIFIED_MESSAGE); }); }); describe("Stripe checkout", () => { const account = { accountId: "unverified", name: "unverified", kind: "user", canEdit: true }; test("packs, Pro and the domain add-on refuse an unverified viewer before reaching Stripe", async () => { const viewer = await viewerOf("unverified"); expect(await gate(() => createSubscriptionCheckout(account as never, viewer))).toBe("gate"); expect(await gate(() => createTopupCheckout(account as never, viewer, "pack"))).toBe("gate"); expect(await gate(() => createDomainAddonCheckout(account as never, viewer))).toBe("gate"); expect(await gate(async () => createSubscriptionCheckout(account as never, await viewerOf("verified")))).toBe( "passed", ); }); }); describe("unverified-account sweep blockers", () => { test("stars, follows and a rename do not keep a stale unverified email reserved", async () => { db.exec(` DELETE FROM repos WHERE owner_id = 'unverified'; INSERT INTO stars (user_id, repo_id, created_at) VALUES ('unverified', 'repo-verified', 0); INSERT INTO follows (follower_id, followee_id, created_at) VALUES ('unverified', 'verified', 0); INSERT INTO activity (id, actor_id, repo_id, kind, data, created_at) VALUES ('a1', 'unverified', NULL, 'rename_account', '{}', 0); `); expect(await reclaimUnverifiedEmail("u@x.dev")).toBe(true); }); test("a GitHub-linked account is trusted and never swept", async () => { db.exec("DELETE FROM repos WHERE owner_id = 'github'"); expect(await reclaimUnverifiedEmail("g@x.dev")).toBe(false); }); }); describe("deletion while unverified", () => { test("an unverified owner may delete its own repository, not one it only administers", async () => { const viewer = await viewerOf("unverified"); db.exec( "INSERT INTO collaborators (repo_id, user_id, permission, created_at) VALUES ('repo-verified', 'unverified', 'admin', 0)", ); const own = { owner: "unverified", repo: "app", confirm: "unverified/app" }; expect(await gate(() => invoke(deleteRepo, viewer, own))).toBe("passed"); expect( await gate(() => invoke(deleteRepo, viewer, { owner: "verified", repo: "app", confirm: "verified/app" })), ).toBe("gate"); db.exec("UPDATE repos SET status = 'failed' WHERE id = 'repo-verified'"); expect(await gate(() => invoke(deleteFailedRepo, viewer, { owner: "verified", repo: "app" }))).toBe("gate"); }); test("the API applies the same ownership rule", async () => { db.exec( "INSERT INTO collaborators (repo_id, user_id, permission, created_at) VALUES ('repo-verified', 'unverified', 'admin', 0)", ); const remove = async (path: string) => { const url = new URL(`http://localhost:8787/api/v1${path}`); const auth = { authorization: `Bearer ${await patFor("unverified")}` }; return handleApiV1(new Request(url, { method: "DELETE", headers: auth }), url); }; expect(await gate(() => remove("/repos/verified/app"))).toBe("gate"); // The owner's own deletion reaches Artifacts, which is not bound here; the shared check is what the route runs. expect(await gate(async () => assertMayDeleteRepo({ owner_id: "unverified" }, await viewerOf("unverified")))).toBe( "passed", ); }); test("an unverified owner may delete an organization only while it is the sole member", async () => { const viewer = await viewerOf("unverified"); db.exec(` INSERT INTO accounts (id, name, kind, created_at) VALUES ('solo', 'solo', 'org', 0); INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('solo', 'unverified', 'owner', 0), ('acme', 'unverified', 'owner', 0); `); expect( await gate(() => invoke(deleteOrganization, viewer, { org: "acme", confirm: "acme", deleteRepos: false })), ).toBe("gate"); expect( await gate(() => invoke(deleteOrganization, viewer, { org: "solo", confirm: "solo", deleteRepos: false })), ).toBe("passed"); }); });