import { beforeEach, describe, expect, mock, test } from "bun:test"; import { fakeD1 } from "./metering/fake-d1.test-helper"; let d1 = fakeD1(); const kv = new Map(); const fakeEnv: Record = { get DB() { return d1.binding; }, KV: { get: async (key: string) => kv.get(key) ?? null, put: async (key: string, value: string) => void kv.set(key, value), delete: async (key: string) => void kv.delete(key), }, PUBLIC_URL: "https://forge.test", METERING_PLANS: "", SUPPORT_EMAIL: "", LEGAL_CONTACT_EMAIL: "legal@forge.test", }; mock.module("cloudflare:workers", () => ({ env: fakeEnv, waitUntil: () => undefined })); const cancelled: string[] = []; let cancelError: Error | null = null; const realConfig = await import("./billing/config"); const realEmail = await import("./email"); mock.module("./billing/config", () => ({ ...realConfig, stripeEnabled: () => true, stripe: () => ({ subscriptions: { retrieve: async (id: string) => ({ id, status: cancelled.includes(id) ? "canceled" : "active" }), cancel: async (id: string, options: { prorate: boolean }) => { if (cancelError) throw cancelError; expect(options.prorate).toBe(false); cancelled.push(id); }, }, }), })); const stoppedContainers: string[] = []; mock.module("./editor/container-admin", () => ({ stopAllAccountContainers: async (accountId: string) => void stoppedContainers.push(accountId), })); const cancelledRuns: string[] = []; mock.module("./ci/run-control", () => ({ cancelRunRow: async (row: { id: string }) => void cancelledRuns.push(row.id), })); const emails: { to: string; subject: string; text: string }[] = []; mock.module("./email", () => ({ ...realEmail, queueEmail: async (to: string, content: { subject: string; text: string }) => void emails.push({ to, subject: content.subject, text: content.text }), emailTemplates: { accountDisabled: (name: string, account: string, message: string) => ({ subject: "Your account was disabled", text: `${name} ${account} ${message}`, html: "", }), }, })); const adminNotices: string[][] = []; mock.module("./notify/deliver", () => ({ notifyUsers: async (input: { recipients: string[]; title: string }) => void adminNotices.push([input.title, ...input.recipients]), })); const droppedMcp: string[] = []; mock.module("./access-revocation", () => ({ dropMcpAccess: async (repoId: string) => void droppedMcp.push(repoId), })); const { sharedScopes } = await import("./test-shared-scopes"); mock.module("./scoped-db", () => ({ accountDb: (id: string) => sharedScopes(d1.db).accountDb(id), repoDb: (id: string) => sharedScopes(d1.db).repoDb(id), stmt: (sql: string, ...params: (string | number | null)[]) => ({ sql, params }), })); const { disableAccount, enableAccount, adminDisableAccount } = await import("./account-disable-actions"); const { accountDisableState, assertAccountActive, listDisabledAccounts, disabledOrgsOf, supportEmail } = await import("./account-disable"); function hostingKeys(accountId: string): boolean { return [...kv.keys()].some((key) => key.includes(accountId) && !key.startsWith("account-disabled-notified")); } function migrate() { d1.db.exec(` ALTER TABLE users ADD COLUMN email TEXT; ALTER TABLE users ADD COLUMN sessions_revoked_at INTEGER; ALTER TABLE workflow_runs ADD COLUMN status TEXT; `); } beforeEach(() => { d1 = fakeD1(); migrate(); kv.clear(); for (const list of [cancelled, stoppedContainers, cancelledRuns, emails, adminNotices, droppedMcp]) list.length = 0; cancelError = null; d1.db.exec(` INSERT INTO accounts (id, name, kind) VALUES ('u1', 'alice', 'user'), ('u2', 'bob', 'user'), ('o1', 'acme', 'org'), ('admin', 'root', 'user'); INSERT INTO users (account_id, email, is_admin) VALUES ('u1', 'alice@x.test', 0), ('u2', 'bob@x.test', 0), ('admin', 'root@x.test', 1); INSERT INTO org_members (org_id, user_id, role) VALUES ('o1', 'u1', 'owner'), ('o1', 'u2', 'member'); INSERT INTO repos (id, owner_id, name) VALUES ('r1', 'o1', 'app'); INSERT INTO workflow_runs (id, repo_id, source_repo_id, status) VALUES ('w1', 'r1', 'r1', 'in_progress'), ('w2', 'r1', 'r1', 'completed'); INSERT INTO billing_customers (account_id, stripe_customer_id, subscription_id, subscription_status, updated_at) VALUES ('o1', 'cus_1', 'sub_plan', 'active', 0); INSERT INTO billing_addons (account_id, addon, subscription_id, status, quantity, updated_at) VALUES ('o1', 'custom_domains', 'sub_domains', 'active', 1, 0); `); }); describe("disableAccount", () => { test("disables, stops billing, hosting, containers and CI, and emails the org's owners once", async () => { expect(await disableAccount({ accountId: "o1", reason: "payment_dispute", note: "dp_1", by: "stripe" })).toBe(true); expect((await accountDisableState("o1"))?.reason).toBe("payment_dispute"); expect(cancelled.sort()).toEqual(["sub_domains", "sub_plan"]); expect(stoppedContainers).toEqual(["o1"]); expect(cancelledRuns).toEqual(["w1"]); expect(hostingKeys("o1")).toBe(true); expect(emails.map((email) => email.to)).toEqual(["alice@x.test"]); expect(emails[0].text).toContain("payment dispute"); expect(emails[0].text).toContain("legal@forge.test"); expect(adminNotices[0]).toContain("admin"); expect(await disableAccount({ accountId: "o1", reason: "payment_dispute", note: "dp_1", by: "stripe" })).toBe( false, ); expect(emails).toHaveLength(1); expect(adminNotices).toHaveLength(1); }); test("already-cancelled subscriptions are fine; other Stripe failures propagate so the webhook retries", async () => { cancelError = Object.assign(new Error("No such subscription"), { code: "resource_missing" }); await disableAccount({ accountId: "o1", reason: "payment_dispute", note: null, by: "stripe" }); cancelError = new Error("Stripe is down"); await expect( disableAccount({ accountId: "o1", reason: "payment_dispute", note: null, by: "stripe" }), ).rejects.toThrow("Stripe is down"); }); test("a retry after a failed step still sends the emails, once", async () => { cancelError = new Error("Stripe is down"); await expect( disableAccount({ accountId: "o1", reason: "payment_dispute", note: null, by: "stripe" }), ).rejects.toThrow("Stripe is down"); expect(emails).toHaveLength(0); cancelError = null; expect(await disableAccount({ accountId: "o1", reason: "payment_dispute", note: null, by: "stripe" })).toBe(false); expect(emails).toHaveLength(1); await disableAccount({ accountId: "o1", reason: "payment_dispute", note: null, by: "stripe" }); expect(emails).toHaveLength(1); }); test("a disabled user is refused with the dispute message and its sessions are revoked", async () => { await disableAccount({ accountId: "u2", reason: "payment_dispute", note: null, by: "stripe" }); await expect(assertAccountActive("u2")).rejects.toThrow( "This account is disabled because of a payment dispute. Email legal@forge.test to resolve it.", ); const user = d1.db.query("SELECT sessions_revoked_at FROM users WHERE account_id = 'u2'").get() as { sessions_revoked_at: number | null; }; expect(user.sessions_revoked_at).not.toBeNull(); await assertAccountActive("u1"); }); test("re-enabling leaves an operator's earlier hosting suspension in place", async () => { d1.db.exec("INSERT INTO account_billing (account_id, hosting_suspended_at, updated_at) VALUES ('o1', 5, 5)"); kv.set("operator", "x"); await disableAccount({ accountId: "o1", reason: "admin", note: null, by: "admin:admin" }); await enableAccount("o1", "admin:admin"); const row = d1.db.query("SELECT hosting_suspended_at AS at FROM account_billing WHERE account_id = 'o1'").get() as { at: number | null; }; expect(row.at).toBe(5); }); test("a dispute on an admin-disabled account keeps the reason but tells admins", async () => { await disableAccount({ accountId: "o1", reason: "admin", note: null, by: "admin:admin" }); await disableAccount({ accountId: "o1", reason: "payment_dispute", note: "dp_9", by: "stripe" }); expect((await accountDisableState("o1"))?.reason).toBe("admin"); expect(adminNotices.some((notice) => notice[0].includes("payment_dispute"))).toBe(true); expect(emails).toHaveLength(1); }); test("members see disabled orgs; re-enabling clears the flag and resumes hosting", async () => { await disableAccount({ accountId: "o1", reason: "admin", note: "abuse", by: "admin:admin" }); expect((await disabledOrgsOf("u2")).map((org) => org.name)).toEqual(["acme"]); expect((await listDisabledAccounts()).map((row) => [row.name, row.reason, row.note])).toEqual([ ["acme", "admin", "abuse"], ]); expect(await enableAccount("o1", "admin:admin")).toBe(true); expect(await accountDisableState("o1")).toBeNull(); expect(hostingKeys("o1")).toBe(false); expect(await enableAccount("o1", "admin:admin")).toBe(false); }); test("admins disable by name but never themselves", async () => { await expect(adminDisableAccount("root", null, "admin")).rejects.toThrow("your own account"); await expect(adminDisableAccount("nobody", null, "admin")).rejects.toThrow("No such account"); await adminDisableAccount("Bob", "spam", "admin"); expect((await accountDisableState("u2"))?.note).toBe("spam"); }); test("SUPPORT_EMAIL wins over the legal contact", () => { fakeEnv.SUPPORT_EMAIL = "help@forge.test"; expect(supportEmail()).toBe("help@forge.test"); fakeEnv.SUPPORT_EMAIL = ""; expect(supportEmail()).toBe("legal@forge.test"); }); }); describe("cached MCP access", () => { test("is dropped for every repository of the account on takedown and again on re-enable", async () => { d1.db.exec("INSERT INTO repos (id, owner_id, name) VALUES ('r2', 'o1', 'site'), ('r3', 'u2', 'other')"); await disableAccount({ accountId: "o1", reason: "admin", note: null, by: "admin:admin" }); expect(droppedMcp.sort()).toEqual(["r1", "r2"]); droppedMcp.length = 0; expect(await enableAccount("o1", "admin:admin")).toBe(true); expect(droppedMcp.sort()).toEqual(["r1", "r2"]); }); });