import { beforeEach, describe, expect, mock, test } from "bun:test"; import { Database } from "bun:sqlite"; import { applyCombinedSchema } from "./test-schema.test-helper"; import type { ReporterKind } from "./abuse"; const published: string[] = []; mock.module("cloudflare:workers", () => ({ waitUntil: () => undefined, env: { PUBLIC_URL: "https://forge.example.com", SITES_DOMAIN: "sites.example.net", SECRETS_KEY: "test-secret", ABUSE_FORWARD_TO: "", KV: { put: async (key: string) => void published.push(key) }, }, })); mock.module("./sites/directory", () => ({ forgetSiteDirectory: async () => 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[]) => ({ meta: { changes: db.query(sql).run(...(params as never[])).changes }, }), stmt: (sql: string, ...params: unknown[]) => ({ sql, params }), })); // Every repo and account object shares the one test database (it still has every table), so seeds stay in one place. const scoped = { 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[]) => ({ meta: { changes: db.query(sql).run(...(params as never[])).changes, last_row_id: 0, }, }), batch: async (statements: Array<{ sql: string; params: unknown[] }>) => statements.map((statement) => ({ meta: { changes: db.query(statement.sql).run(...(statement.params as never[])).changes, last_row_id: 0, }, })), }; mock.module("./scoped-db", () => ({ repoDb: (scopeId: string) => ({ scopeId, ...scoped }), accountDb: (scopeId: string) => ({ scopeId, ...scoped }), stmt: (sql: string, ...params: unknown[]) => ({ sql, params }), })); mock.module("./repo-state", () => ({ issuesChanged: () => undefined })); const notified: string[] = []; mock.module("./account-disable-actions", () => ({ notifyAdmins: async (title: string) => void notified.push(title), })); const invalidated: string[] = []; mock.module("./user-inbox", () => ({ invalidateUnread: async (ids: string[]) => void invalidated.push(...ids), })); mock.module("./events/emit", () => ({ publicUrl: (path: string) => `https://forge.example.com${path}`, })); const mcpDropped: string[] = []; mock.module("./access-revocation", () => ({ dropMcpAccess: async (id: string) => void mcpDropped.push(id), })); mock.module("./sites/deploy", () => ({ loadSiteById: async (id: string) => db.query("SELECT * FROM sites WHERE id = ?").get(id) ?? null, })); const { actOnAbuseReport, guessAbuseCategory, recordAbuseReport, restoreSite, siteIdForUrl } = await import("./abuse"); const { resolveAbuseTarget } = await import("./abuse-targets"); const { setModerationHidden } = await import("./moderation"); function locator(kind: string, key: string, scopeId: string) { db.query("INSERT OR REPLACE INTO scope_locators (kind, key, scope_id) VALUES (?, ?, ?)").run(kind, key, scopeId); } function site(id: string, label: string, scratch: number, disabled: string | null = null) { db.query( `INSERT INTO sites (id, owner_id, label, source, scratch, disabled_reason, created_at, updated_at) VALUES (?, 'owner', ?, 'upload', ?, ?, 0, 0)`, ).run(id, label, scratch, disabled); locator("site", id, "site-repo"); locator("site_host", label, id); } function state(id: string) { return db.query("SELECT disabled_reason, paused_at FROM sites WHERE id = ?").get(id) as { disabled_reason: string | null; paused_at: number | null; }; } async function report( url: string, reporter: string, category: "phishing" | "spam" = "spam", reporterKind: ReporterKind = "ip", ) { return recordAbuseReport({ source: "web", targetUrl: url, category, details: "", reporterEmail: null, reporterKey: reporter, reporterKind, }); } beforeEach(() => { db.exec( "DELETE FROM scope_locators; DELETE FROM users; DELETE FROM admin_audit_log; DELETE FROM abuse_reports; DELETE FROM site_deployments; DELETE FROM sites; DELETE FROM notifications; DELETE FROM discussion_comments; DELETE FROM discussions; DELETE FROM discussion_categories; DELETE FROM review_comments; DELETE FROM comments; DELETE FROM issues; DELETE FROM repos; DELETE FROM accounts;", ); db.exec( "INSERT INTO accounts (id, name, kind, created_at) VALUES ('owner', 'owner', 'user', 0), ('admin', 'admin', 'user', 0)", ); db.exec( "INSERT INTO users (account_id, email) VALUES ('owner', 'owner@example.com'), ('admin', 'admin@example.com')", ); published.length = 0; notified.length = 0; mcpDropped.length = 0; }); describe("siteIdForUrl", () => { test("resolves site labels, preview labels and custom domains", async () => { site("s1", "blog--alice", 0); db.exec("UPDATE sites SET custom_domain = 'www.alice.dev' WHERE id = 's1'"); locator("site_host", "www.alice.dev", "s1"); locator("site_host", "feat--blog--alice", "s1"); db.exec( `INSERT INTO site_deployments (id, site_id, environment, label, source, ref, sha, kind, status, created_at) VALUES ('d1', 's1', 'preview', 'feat--blog--alice', 'upload', 'r', 'sha', 'static', 'active', 0)`, ); expect(await siteIdForUrl("https://blog--alice.sites.example.net/login")).toBe("s1"); expect(await siteIdForUrl("https://feat--blog--alice.sites.example.net/")).toBe("s1"); expect(await siteIdForUrl("https://WWW.alice.dev/x")).toBe("s1"); expect(await siteIdForUrl("https://forge.example.com/alice/blog")).toBeNull(); expect(await siteIdForUrl("not a url")).toBeNull(); }); }); describe("auto-pause", () => { test("owned sites ignore anonymous reporters and pause only on many signed-in ones", async () => { site("s1", "blog--alice", 0); const url = "https://blog--alice.sites.example.net/"; for (const reporter of ["ip:1", "ip:1", "ip:2", "ip:3", "ip:4", "ip:5", "ip:6", "ip:7"]) { await report(url, reporter, "phishing"); } expect(state("s1").paused_at).toBeNull(); expect(db.query("SELECT COUNT(*) AS n FROM abuse_reports").get()).toEqual({ n: 7, }); for (const user of ["user:a", "user:b", "user:c"]) await report(url, user, "phishing", "user"); expect(state("s1").paused_at).toBeNull(); const result = await report(url, "user:d", "phishing", "user"); expect(result.paused).toBe(true); expect(state("s1").disabled_reason).toBe("Paused pending abuse review"); expect(published).toContain("sites:meta:s1"); expect(notified.some((title) => title.includes("auto-paused"))).toBe(true); }); test("forged (unauthenticated) email never counts toward a pause", async () => { site("s5", "free--scratch", 1); const url = "https://free--scratch.sites.example.net/"; for (const domain of ["a.com", "b.com", "c.com", "d.com", "e.com"]) { await report(url, `email-domain:${domain}`, "phishing", "email-unverified"); } expect(state("s5").paused_at).toBeNull(); expect((await report(url, "email-domain:f.com", "phishing", "email-verified")).paused).toBe(true); }); test("admins hear about a target at most once an hour, and never about weight-0 reports", async () => { const reports = () => notified.filter((title) => title.startsWith("New abuse report")).length; site("s6", "flood--alice", 0); const url = "https://flood--alice.sites.example.net/"; for (const domain of ["a.com", "b.com", "c.com"]) { await report(url, `email-domain:${domain}`, "spam", "email-unverified"); } expect(reports()).toBe(0); await report(url, "ip:1"); await report(url, "ip:2"); await report(url, "user:x", "spam", "user"); expect(reports()).toBe(1); await report("https://elsewhere.example.org/", "ip:1"); await report("https://elsewhere.example.org/", "ip:2"); expect(reports()).toBe(2); db.exec("UPDATE abuse_reports SET created_at = created_at - 2 * 60 * 60 * 1000"); await report(url, "ip:3"); expect(reports()).toBe(3); }); test("phishing on a scratch site pauses after two reporters", async () => { site("s2", "abc--scratch", 1); const url = "https://abc--scratch.sites.example.net/"; expect((await report(url, "ip:1", "phishing")).paused).toBe(false); expect((await report(url, "ip:2", "phishing")).paused).toBe(true); }); test("never overrides a different disable and restore only lifts abuse pauses", async () => { site("s3", "old--alice", 0, "Repository was deleted"); const url = "https://old--alice.sites.example.net/"; for (const reporter of ["a", "b", "c", "d", "e"]) await report(url, reporter, "phishing"); expect(state("s3")).toEqual({ disabled_reason: "Repository was deleted", paused_at: null, }); expect(await restoreSite("s3")).toBe(false); expect(state("s3").disabled_reason).toBe("Repository was deleted"); }); }); describe("admin actions", () => { test("restoring dismisses every open report for the site", async () => { site("s4", "shop--alice", 1); const url = "https://shop--alice.sites.example.net/"; await report(url, "x", "phishing"); await report(url, "y", "phishing"); const { id } = db.query("SELECT id FROM abuse_reports LIMIT 1").get() as { id: string; }; await actOnAbuseReport(id, "restore", "admin"); expect(state("s4")).toEqual({ disabled_reason: null, paused_at: null }); expect(db.query("SELECT DISTINCT status, resolved_by FROM abuse_reports").all()).toEqual([ { status: "dismissed", resolved_by: "admin" }, ]); }); test("pause is rejected for reports that are not about a hosted site", async () => { await report("https://forge.example.com/alice/repo", "z"); const { id } = db.query("SELECT id FROM abuse_reports LIMIT 1").get() as { id: string; }; await expect(actOnAbuseReport(id, "pause", "admin")).rejects.toThrow("not linked to a hosted site"); }); }); describe("forge targets", () => { function seedForge() { db.exec(`INSERT INTO repos (id, owner_id, name, artifacts_name, artifacts_remote, visibility, created_at, updated_at) VALUES ('r1', 'owner', 'blog', 'a1', 'x', 'public', 0, 0)`); db.exec(`INSERT INTO issues (id, repo_id, number, author_id, title, state, created_at, updated_at) VALUES ('i1', 'r1', 7, 'owner', 't', 'open', 0, 0)`); db.exec(`INSERT INTO comments (id, issue_id, author_id, body, created_at, updated_at) VALUES ('c1', 'i1', 'owner', 'spam', 0, 0)`); locator("issue", "i1", "r1"); locator("comment", "c1", "r1"); } test("resolves users, repos, issues and comments", async () => { seedForge(); expect(await resolveAbuseTarget("https://forge.example.com/OWNER")).toEqual({ kind: "user", id: "owner" }); expect(await resolveAbuseTarget("https://forge.example.com/owner/blog/tree/main")).toEqual({ kind: "repo", id: "r1", }); expect(await resolveAbuseTarget("https://forge.example.com/owner/blog/issues/7")).toEqual({ kind: "issue", id: "i1", }); expect(await resolveAbuseTarget("https://forge.example.com/owner/blog/pull/7#comment-c1")).toEqual({ kind: "comment", id: "c1", }); expect(await resolveAbuseTarget("https://forge.example.com/owner/blog/issues/7#comment-nope")).toEqual({ kind: "issue", id: "i1", }); expect(await resolveAbuseTarget("https://forge.example.com/nobody")).toBeNull(); }); test("hiding a reported comment closes its reports and is audited", async () => { seedForge(); await report("https://forge.example.com/owner/blog/issues/7#comment-c1", "ip:1"); await report("https://forge.example.com/owner/blog/issues/7#comment-c1", "user:x", "spam", "user"); const { id } = db.query("SELECT id FROM abuse_reports LIMIT 1").get() as { id: string; }; await actOnAbuseReport(id, "hide", "admin", "spam link"); expect( ( db.query("SELECT hidden_at FROM comments WHERE id = 'c1'").get() as { hidden_at: number; } ).hidden_at, ).toBeGreaterThan(0); expect(db.query("SELECT DISTINCT status FROM abuse_reports").all()).toEqual([{ status: "actioned" }]); expect( db.query("SELECT action, target_kind, target_id, reason, actor_id FROM admin_audit_log ORDER BY action").all(), ).toEqual([ { action: "abuse.hide", target_kind: "abuse_report", target_id: id, reason: "spam link", actor_id: "admin", }, { action: "comment.hide", target_kind: "comment", target_id: "c1", reason: "spam link", actor_id: "admin", }, ]); await actOnAbuseReport(id, "unhide", "admin"); expect(db.query("SELECT hidden_at FROM comments WHERE id = 'c1'").get()).toEqual({ hidden_at: null }); }); test("hiding a comment drops it from the issue's count once, unhiding restores it", async () => { seedForge(); db.exec("UPDATE issues SET comments_count = 1 WHERE id = 'i1'"); const count = () => (db.query("SELECT comments_count AS n FROM issues WHERE id = 'i1'").get() as { n: number }).n; await setModerationHidden("comment", "c1", true, "admin"); await setModerationHidden("comment", "c1", true, "admin"); expect(count()).toBe(0); await setModerationHidden("comment", "c1", false, "admin"); expect(count()).toBe(1); }); test("review and discussion comments resolve from their anchors and can be hidden", async () => { seedForge(); db.exec(`INSERT INTO review_comments (id, issue_id, author_id, path, line, side, commit_sha, body, created_at, updated_at) VALUES ('rc1', 'i1', 'owner', 'a.ts', 1, 'RIGHT', 'sha', 'x', 0, 0)`); db.exec(`INSERT INTO discussion_categories (id, repo_id, name) VALUES ('cat', 'r1', 'General')`); db.exec(`INSERT INTO discussions (id, repo_id, number, category_id, author_id, title, comments_count, created_at, updated_at) VALUES ('d1', 'r1', 3, 'cat', 'owner', 't', 1, 0, 0)`); db.exec(`INSERT INTO discussion_comments (id, discussion_id, author_id, body, created_at, updated_at) VALUES ('dc1', 'd1', 'owner', 'x', 0, 0)`); locator("review_comment", "rc1", "r1"); locator("discussion_comment", "dc1", "r1"); expect(await resolveAbuseTarget("https://forge.example.com/owner/blog/pull/7#review-comment-rc1")).toEqual({ kind: "review_comment", id: "rc1", }); expect( await resolveAbuseTarget("https://forge.example.com/owner/blog/discussions/3#discussion-comment-dc1"), ).toEqual({ kind: "discussion_comment", id: "dc1" }); expect(await resolveAbuseTarget("https://forge.example.com/owner/blog/discussions/3")).toEqual({ kind: "repo", id: "r1", }); await setModerationHidden("review_comment", "rc1", true, "admin"); await setModerationHidden("discussion_comment", "dc1", true, "admin"); expect(db.query("SELECT hidden_at IS NOT NULL AS h FROM review_comments").get()).toEqual({ h: 1 }); expect(db.query("SELECT comments_count AS n FROM discussions").get()).toEqual({ n: 0 }); }); test("hiding an open issue or pull request moves the repository's open counters once", async () => { seedForge(); db.exec(`INSERT INTO issues (id, repo_id, number, author_id, title, state, is_pull, created_at, updated_at) VALUES ('p1', 'r1', 8, 'owner', 'pr', 'open', 1, 0, 0), ('i2', 'r1', 9, 'owner', 'done', 'closed', 0, 0, 0)`); locator("issue", "p1", "r1"); locator("issue", "i2", "r1"); db.exec("UPDATE repos SET open_issues_count = 1, open_pulls_count = 1 WHERE id = 'r1'"); const counts = () => db.query("SELECT open_issues_count AS issues, open_pulls_count AS pulls FROM repos WHERE id = 'r1'").get(); await setModerationHidden("issue", "i1", true, "admin"); await setModerationHidden("issue", "i1", true, "admin"); await setModerationHidden("issue", "p1", true, "admin"); await setModerationHidden("issue", "i2", true, "admin"); expect(counts()).toEqual({ issues: 0, pulls: 0 }); await setModerationHidden("issue", "i1", false, "admin"); await setModerationHidden("issue", "p1", false, "admin"); await setModerationHidden("issue", "i2", false, "admin"); expect(counts()).toEqual({ issues: 1, pulls: 1 }); }); test("hiding or restoring a repository drops cached MCP access", async () => { seedForge(); await setModerationHidden("repo", "r1", true, "admin"); await setModerationHidden("repo", "r1", true, "admin"); await setModerationHidden("repo", "r1", false, "admin"); expect(mcpDropped).toEqual(["r1", "r1"]); }); test("hiding an issue removes its notifications", async () => { seedForge(); db.exec(`INSERT INTO notifications (id, user_id, repo_id, reason, title, url, created_at) VALUES ('n1', 'admin', 'r1', 'mention', 'spam', 'https://forge.example.com/owner/blog/issues/7', 0), ('n2', 'admin', 'r1', 'mention', 'spam', 'https://forge.example.com/owner/blog/issues/7#comment-c1', 0), ('n3', 'admin', 'r1', 'mention', 'other', 'https://forge.example.com/owner/blog/issues/70', 0)`); await setModerationHidden("issue", "i1", true, "admin"); expect(db.query("SELECT id FROM notifications").all()).toEqual([{ id: "n3" }]); expect(invalidated).toContain("admin"); }); test("hide is rejected for user and site targets", async () => { seedForge(); await report("https://forge.example.com/owner", "ip:1"); const { id } = db.query("SELECT id FROM abuse_reports LIMIT 1").get() as { id: string; }; await expect(actOnAbuseReport(id, "hide", "admin")).rejects.toThrow("not about a repository"); }); }); test("guessAbuseCategory", () => { expect(guessAbuseCategory("This page is a PayPal phishing login")).toBe("phishing"); expect(guessAbuseCategory("DMCA takedown notice")).toBe("copyright"); expect(guessAbuseCategory("hello")).toBe("other"); });