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"; const kv = new Map(); const destroyed: string[] = []; class StubEntrypoint {} mock.module("cloudflare:workers", () => ({ env: { PUBLIC_URL: "https://forge.example.com", ACCOUNT_STATE: { idFromName: (name: string) => name, get: (id: string) => ({ destroy: async () => void destroyed.push(id) }), }, KV: { get: async (key: string, type?: string) => { const value = kv.get(key) ?? null; return type === "json" && value !== null ? JSON.parse(value) : value; }, put: async (key: string, value: string) => { kv.set(key, value); }, delete: async (key: string) => { kv.delete(key); }, }, }, waitUntil: () => undefined, WorkflowEntrypoint: StubEntrypoint, DurableObject: StubEntrypoint, WorkerEntrypoint: StubEntrypoint, })); mock.module("@tanstack/react-start/server", () => ({ getRequestHeader: () => 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[] }[]) => { db.transaction(() => { 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 deletedRepos: string[] = []; mock.module("./repo-ops", () => ({ deleteRepository: async (repo: { id: string }) => { deletedRepos.push(repo.id); db.query("DELETE FROM repos WHERE id = ?").run(repo.id); }, })); mock.module("./repos", () => ({ loadRepoById: async (id: string) => db.query("SELECT * FROM repos WHERE id = ?").get(id) ?? null, repoReadableSql: (alias: string, user: string) => `(${alias}.visibility = 'public' OR ${alias}.owner_id = ${user})`, })); mock.module("./ci/runner-registration", () => ({ deleteRunnerRow: async (id: string) => { db.query("DELETE FROM runners WHERE id = ?").run(id); }, })); const cancelled: string[] = []; let stripeOn = true; mock.module("./billing/config", () => ({ checkoutDisabled: () => false, liveMode: () => false, stripeModeError: () => null, stripeEnabled: () => stripeOn, stripe: () => ({ subscriptions: { cancel: async (id: string) => { cancelled.push(id); }, }, }), })); mock.module("./billing/checkout", () => ({ ACTIVE_STATUSES: new Set(["active", "trialing", "past_due"]), customerFor: async (id: string) => db.query("SELECT * FROM billing_customers WHERE account_id = ?").get(id) ?? null, })); const emails: string[] = []; mock.module("./email", () => ({ emailTemplates: { accountDeleted: (name: string) => ({ subject: `deleted ${name}` }) }, queueEmail: async (to: string) => { emails.push(to); }, })); const cancelledAddons: string[] = []; mock.module("./billing/domain-addon", () => ({ cancelDomainAddon: async (id: string) => void cancelledAddons.push(id), })); mock.module("./security/signatures", () => ({ listSigningKeys: async () => [] })); const disabledSites: string[] = []; mock.module("./sites/site", () => ({ disableSite: async (site: { id: string }) => { disabledSites.push(site.id); db.query("DELETE FROM sites WHERE id = ?").run(site.id); }, })); mock.module("./sites/store", () => ({ sitesOwnedBy: async (ownerId: string) => db.query("SELECT * FROM sites WHERE owner_id = ?").all(ownerId), })); const { sharedScopes } = await import("./test-shared-scopes"); mock.module("./scoped-db", () => sharedScopes(db)); const { deleteUserAccount, soleOwnedOrgs } = await import("./account-delete"); const { buildAccountExport, readAccountExport, requestAccountExport } = await import("./account-export"); const alice: Viewer = { id: "alice", name: "alice", displayName: null, email: "a@x.test", isAdmin: false, emailVerified: true, }; function seed(): void { db.exec("PRAGMA foreign_keys = ON"); db.exec(` DELETE FROM comments; DELETE FROM issues; DELETE FROM repos; DELETE FROM org_members; DELETE FROM billing_customers; DELETE FROM retired_account_names; DELETE FROM users; DELETE FROM accounts; INSERT INTO accounts (id, name, kind, created_at) VALUES ('alice', 'alice', 'user', 0), ('bob', 'bob', 'user', 0), ('acme', 'acme', 'org', 0); INSERT INTO users (account_id, email, email_verified_at, is_admin) VALUES ('alice', 'a@x.test', 1, 0), ('bob', 'b@x.test', 1, 0); INSERT INTO account_redirects (old_name, account_id, created_at) VALUES ('alice-old', 'alice', 0); INSERT INTO repos (id, owner_id, name, visibility, default_branch, artifacts_name, artifacts_remote, created_at, updated_at) VALUES ('r-alice', 'alice', 'mine', 'public', 'main', 'a1', 'x', 0, 0), ('r-bob', 'bob', 'theirs', 'public', 'main', 'b1', 'x', 0, 0); INSERT INTO issues (id, repo_id, number, title, body, state, author_id, is_pull, created_at, updated_at) VALUES ('i-bob', 'r-bob', 1, 'Bug', 'by alice', 'open', 'alice', 0, 0, 0); INSERT INTO comments (id, issue_id, author_id, body, created_at, updated_at) VALUES ('c1', 'i-bob', 'alice', 'hi', 0, 0); INSERT INTO stars (user_id, repo_id, created_at) VALUES ('alice', 'r-bob', 0); INSERT INTO access_tokens (id, user_id, name, token_hash, token_prefix, scopes, created_at) VALUES ('t1', 'alice', 'ci', 'hash', 'gitfor_pat_ab', 'repo', 0); INSERT INTO sessions (token_hash, id, user_id, created_at, last_seen_at, expires_at) VALUES ('s', 's1', 'alice', 0, 0, 9e15); `); kv.clear(); deletedRepos.length = 0; cancelled.length = 0; emails.length = 0; disabledSites.length = 0; stripeOn = true; } beforeEach(seed); describe("deleteUserAccount", () => { test("requires the username typed exactly", async () => { await expect(deleteUserAccount({ viewer: alice, confirm: "Alice" })).rejects.toThrow("Type alice to confirm"); }); test("refuses while the user is the sole owner of an organization", async () => { db.exec("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('acme', 'alice', 'owner', 0)"); expect(await soleOwnedOrgs("alice")).toEqual(["acme"]); await expect(deleteUserAccount({ viewer: alice, confirm: "alice" })).rejects.toThrow("only owner of acme"); db.exec("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('acme', 'bob', 'owner', 0)"); expect(await soleOwnedOrgs("alice")).toEqual([]); }); test("deletes repos, ghosts authored content, retires names and cascades credentials", async () => { db.exec(`INSERT INTO billing_customers (account_id, stripe_customer_id, subscription_id, subscription_status, updated_at) VALUES ('alice', 'cus_1', 'sub_1', 'active', 0)`); db.exec(`INSERT INTO sites (id, owner_id, repo_id, label, source, created_at, updated_at) VALUES ('site-upload', 'alice', NULL, 'alice-upload', 'upload', 0, 0)`); const result = await deleteUserAccount({ viewer: alice, confirm: "alice" }); expect(disabledSites).toEqual(["site-upload"]); expect(result.deletedRepos).toEqual(["mine"]); expect(deletedRepos).toEqual(["r-alice"]); expect(cancelled).toEqual(["sub_1"]); expect(cancelledAddons).toEqual(["alice"]); expect(db.query("SELECT id FROM accounts WHERE id = 'alice'").get()).toBeNull(); expect(destroyed).toContain("alice"); expect(db.query("SELECT author_id FROM issues WHERE id = 'i-bob'").get()).toEqual({ author_id: "ghost" }); expect(db.query("SELECT author_id FROM comments WHERE id = 'c1'").get()).toEqual({ author_id: "ghost" }); const retired = db.query("SELECT name FROM retired_account_names ORDER BY name").all(); expect(retired).toEqual([{ name: "alice" }, { name: "alice-old" }]); for (const table of ["access_tokens", "sessions", "stars"]) { expect(db.query(`SELECT COUNT(*) AS n FROM ${table} WHERE user_id = 'alice'`).get()).toEqual({ n: 0 }); } expect(emails).toEqual(["a@x.test"]); }); test("keeps the account when the subscription cannot be cancelled", async () => { stripeOn = false; db.exec(`INSERT INTO billing_customers (account_id, stripe_customer_id, subscription_id, subscription_status, updated_at) VALUES ('alice', 'cus_1', 'sub_1', 'active', 0)`); await expect(deleteUserAccount({ viewer: alice, confirm: "alice" })).rejects.toThrow("Billing is unavailable"); expect(db.query("SELECT id FROM accounts WHERE id = 'alice'").get()).toEqual({ id: "alice" }); expect(deletedRepos).toEqual([]); }); test("refuses to remove the last administrator", async () => { db.exec("UPDATE users SET is_admin = 1 WHERE account_id = 'alice'"); await expect( deleteUserAccount({ viewer: { ...alice, isAdmin: true, emailVerified: true }, confirm: "alice" }), ).rejects.toThrow("only administrator"); }); }); describe("account export", () => { test("includes authored content and token metadata but never token hashes", async () => { const archive = await buildAccountExport("alice"); expect(archive.profile.email).toBe("a@x.test"); expect(archive.issues.items).toHaveLength(1); expect(archive.comments.items).toHaveLength(1); expect(archive.stars).toHaveLength(1); expect(JSON.stringify(archive.tokens)).not.toContain("hash"); }); test("is limited to one archive a day and readable only by its owner", async () => { const meta = await requestAccountExport(alice); await expect(requestAccountExport(alice)).rejects.toThrow("once a day"); const body = await readAccountExport(alice, meta.id); expect(JSON.parse(body ?? "{}").profile.name).toBe("alice"); const bob: Viewer = { ...alice, id: "bob", name: "bob" }; expect(await readAccountExport(bob, meta.id)).toBeNull(); }); test("account deletion discards a pending archive", async () => { await requestAccountExport(alice); await deleteUserAccount({ viewer: alice, confirm: "alice" }); expect([...kv.keys()]).toEqual([]); }); });