| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { |
| 3 | block, |
| 4 | blockersOf, |
| 5 | blockList, |
| 6 | blockSubjects, |
| 7 | cleanReport, |
| 8 | cleanResolution, |
| 9 | createReport, |
| 10 | isBlocked, |
| 11 | MAX_REPORTS_PER_DAY, |
| 12 | minimizedNote, |
| 13 | minimizeReason, |
| 14 | openReportCount, |
| 15 | reports, |
| 16 | setReportState, |
| 17 | suspend, |
| 18 | suspendReason, |
| 19 | unblock, |
| 20 | unsuspend, |
| 21 | visibleTo, |
| 22 | } from "../../src/lib/moderation.js"; |
| 23 | import { Directory, type Account } from "../../src/lib/directory.js"; |
| 24 | import { blockedBy, killSwitches } from "../../src/lib/killswitch.js"; |
| 25 | import { sha256 } from "../../src/lib/util.js"; |
| 26 | import type { Env } from "../../src/env.js"; |
| 27 | import { testD1 } from "./stubs/d1.js"; |
| 28 | |
| 29 | const at = "2026-10-01T00:00:00.000Z"; |
| 30 | const later = "2099-01-01T00:00:00.000Z"; |
| 31 | |
| 32 | /** A D1 with ann (site administrator), bob, cat and bob's agent bot, the organization acme (ann owns it, cat is a member), and repositories acme/app and bob/tool; REPO answers stopActions. */ |
| 33 | function setup() { |
| 34 | const DB = testD1(); |
| 35 | const add = (id: string, kind: string, siteAdmin = 0, owner: string | null = null) => |
| 36 | DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, status, site_admin, owner_id, created_at) VALUES (?, ?, ?, 'active', ?, ?, ?)").run(id, id, kind, siteAdmin, owner, at); |
| 37 | add("ann", "user", 1); |
| 38 | add("bob", "user"); |
| 39 | add("cat", "user"); |
| 40 | add("bot", "agent", 0, "bob"); |
| 41 | add("acme", "org"); |
| 42 | DB.sqlite.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('acme', 'ann', 'owner', ?), ('acme', 'cat', 'member', ?)").run(at, at); |
| 43 | const repo = (id: string, owner: string) => DB.sqlite.prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES (?, ?, ?, '', 'public', 'main', ?, ?)").run(id, owner, id, at, at); |
| 44 | repo("app", "acme"); |
| 45 | repo("tool", "bob"); |
| 46 | const stops: string[] = []; |
| 47 | const REPO = { idFromName: (n: string) => n, get: (id: string) => ({ stopActions: async () => (stops.push(id), { runs: 1, jobs: 1, sandboxes: 0 }) }) }; |
| 48 | const audits: Array<{ action: string; target?: string; detail?: unknown }> = []; |
| 49 | const audit = async (e: { action: string; target?: string; detail?: unknown }) => void audits.push(e); |
| 50 | const env = { DB, REPO } as unknown as Env; |
| 51 | const acct = async (id: string) => (await new Directory(env).account(id))!; |
| 52 | return { env, DB, stops, audits, audit, acct }; |
| 53 | } |
| 54 | |
| 55 | const target = { kind: "issue" as const, target: "acme/app#1", url: "https://x/acme/app/issues/1", accountId: "bob", repoId: "app", excerpt: "buy now" }; |
| 56 | |
| 57 | describe("reports", () => { |
| 58 | it("take a known reason and a bounded note, and need a note for 'something else'", () => { |
| 59 | expect(cleanReport({ reason: " Spam ", body: " lots of links\r\n" })).toEqual({ reason: "spam", body: "lots of links" }); |
| 60 | expect(cleanReport({ reason: "harassment" })).toEqual({ reason: "harassment", body: "" }); |
| 61 | expect(() => cleanReport({ reason: "boring" })).toThrow(/reason must be one of/); |
| 62 | expect(() => cleanReport({ reason: "other", body: " " })).toThrow(/say what is wrong/); |
| 63 | expect(() => cleanReport({ reason: "spam", body: "x".repeat(2001) })).toThrow(/at most 2000/); |
| 64 | expect(() => cleanReport({ reason: "spam", body: 3 })).toThrow(/must be a string/); |
| 65 | expect(() => cleanResolution(" ")).toThrow(/write what you did/); |
| 66 | expect(cleanResolution(" removed it ")).toBe("removed it"); |
| 67 | }); |
| 68 | |
| 69 | it("land in the queue once per thing, are capped per day, and are resolved and reopened", async () => { |
| 70 | const { env, acct } = setup(); |
| 71 | const cat = await acct("cat"); |
| 72 | const r = await createReport(env, cat, target, { reason: "spam", body: "ads" }); |
| 73 | expect(r).toMatchObject({ reporter_login: "cat", account_login: "bob", kind: "issue", target: "acme/app#1", excerpt: "buy now", state: "open" }); |
| 74 | await expect(createReport(env, cat, target, { reason: "spam", body: "" })).rejects.toThrow(/already reported/); |
| 75 | expect(await openReportCount(env)).toBe(1); |
| 76 | expect((await reports(env, "open")).items.map((x) => x.id)).toEqual([r.id]); |
| 77 | |
| 78 | const done = await setReportState(env, r.id, { login: "ann" }, "resolved", "deleted the issue"); |
| 79 | expect(done).toMatchObject({ state: "resolved", resolved_by: "ann", resolution: "deleted the issue" }); |
| 80 | expect((await reports(env, "open")).total).toBe(0); |
| 81 | expect((await reports(env, "resolved")).total).toBe(1); |
| 82 | // Resolved, the same thing can be reported again. |
| 83 | await createReport(env, cat, target, { reason: "spam", body: "" }); |
| 84 | expect((await setReportState(env, r.id, { login: "ann" }, "open", null))?.resolution).toBeNull(); |
| 85 | expect((await reports(env, "all")).total).toBe(2); |
| 86 | expect(await setReportState(env, "nope", { login: "ann" }, "resolved", "x")).toBeNull(); |
| 87 | |
| 88 | for (let i = 2; i < MAX_REPORTS_PER_DAY; i++) await createReport(env, cat, { ...target, target: `acme/app#${i + 10}` }, { reason: "spam", body: "" }); |
| 89 | await expect(createReport(env, cat, { ...target, target: "acme/app#99" }, { reason: "spam", body: "" })).rejects.toThrow(/at most 20 reports/); |
| 90 | }); |
| 91 | }); |
| 92 | |
| 93 | describe("blocks", () => { |
| 94 | it("cover an agent through its owner", () => { |
| 95 | expect(blockSubjects({ id: "bot", kind: "agent", owner_id: "bob" })).toEqual(["bot", "bob"]); |
| 96 | expect(blockSubjects({ id: "bob", kind: "user", owner_id: null })).toEqual(["bob"]); |
| 97 | }); |
| 98 | |
| 99 | it("end the blocked person's collaboration and pending invitations, and are lifted by unblocking", async () => { |
| 100 | const { env, DB, acct } = setup(); |
| 101 | DB.sqlite.prepare("INSERT INTO repo_access (repo_id, account_id, role) VALUES ('app', 'bob', 'write'), ('app', 'bot', 'write'), ('tool', 'cat', 'write')").run(); |
| 102 | const invite = (id: string, by: string, who: string | null, org: string | null, repo: string | null) => |
| 103 | DB.sqlite.prepare("INSERT INTO invites (id, org_id, repo_id, account_id, role, token_hash, invited_by, created_at, expires_at) VALUES (?, ?, ?, ?, 'member', ?, ?, ?, ?)").run(id, org, repo, who, id, by, at, later); |
| 104 | invite("i1", "ann", "bob", "acme", null); |
| 105 | invite("i2", "ann", "bob", null, "app"); |
| 106 | invite("i3", "cat", "bob", null, null); |
| 107 | const acme = await acct("acme"); |
| 108 | const ann = await acct("ann"); |
| 109 | const bob = await acct("bob"); |
| 110 | |
| 111 | expect(await block(env, acme, bob, ann)).toBe(true); |
| 112 | expect(await block(env, acme, bob, ann)).toBe(false); |
| 113 | expect(await isBlocked(env, "acme", ["bob"])).toBe(true); |
| 114 | expect(await isBlocked(env, "acme", blockSubjects(await acct("bot")))).toBe(true); |
| 115 | expect(await isBlocked(env, "bob", ["acme"])).toBe(false); |
| 116 | expect([...(await blockersOf(env, ["bob"]))]).toEqual(["acme"]); |
| 117 | expect((await blockList(env, "acme")).map((b) => [b.account.login, b.createdBy])).toEqual([["bob", "ann"]]); |
| 118 | expect(DB.sqlite.prepare("SELECT repo_id, account_id FROM repo_access ORDER BY repo_id").all()).toEqual([{ repo_id: "tool", account_id: "cat" }]); |
| 119 | expect(DB.sqlite.prepare("SELECT id FROM invites").all()).toEqual([{ id: "i3" }]); |
| 120 | |
| 121 | expect(await unblock(env, "acme", "bob")).toBe(true); |
| 122 | expect(await unblock(env, "acme", "bob")).toBe(false); |
| 123 | expect(await isBlocked(env, "acme", ["bob"])).toBe(false); |
| 124 | }); |
| 125 | |
| 126 | it("can't block yourself, an organization's own member, or anything but a person", async () => { |
| 127 | const { env, acct } = setup(); |
| 128 | const [ann, bob, cat, acme, bot] = await Promise.all(["ann", "bob", "cat", "acme", "bot"].map(acct)); |
| 129 | await expect(block(env, bob, bob, bob)).rejects.toThrow(/yourself/); |
| 130 | await expect(block(env, acme, cat, ann)).rejects.toThrow(/member of acme/); |
| 131 | await expect(block(env, bob, acme, bob)).rejects.toThrow(/only people/); |
| 132 | await expect(block(env, bob, bot!, bob)).rejects.toThrow(/only people/); |
| 133 | // Blocking a person stops them being added back as a collaborator or invited. |
| 134 | await block(env, bob, cat, bob); |
| 135 | const tool = (await new Directory(env).repo("bob", "tool"))!; |
| 136 | await expect(new Directory(env).grantRepoAccess(tool, bob!, cat!, "write")).rejects.toThrow(/bob blocked cat/); |
| 137 | // Nor their agents. |
| 138 | await block(env, acme!, bob!, ann!); |
| 139 | const app = (await new Directory(env).repo("acme", "app"))!; |
| 140 | await expect(new Directory(env).grantRepoAccess(app, ann!, bot!, "write")).rejects.toThrow(/acme blocked bot's owner/); |
| 141 | }); |
| 142 | }); |
| 143 | |
| 144 | describe("suspension", () => { |
| 145 | it("needs a reason, and hides a suspended person's content from all but site administrators", () => { |
| 146 | expect(suspendReason(" spam bot ")).toBe("spam bot"); |
| 147 | expect(() => suspendReason("")).toThrow(/give a reason/); |
| 148 | expect(() => suspendReason("x".repeat(501))).toThrow(/at most 500/); |
| 149 | expect(visibleTo({ suspended_at: null }, null)).toBe(true); |
| 150 | expect(visibleTo({ suspended_at: at }, null)).toBe(false); |
| 151 | expect(visibleTo({ suspended_at: at }, { site_admin: 0 } as Account)).toBe(false); |
| 152 | expect(visibleTo({ suspended_at: at }, { site_admin: 1 } as Account)).toBe(true); |
| 153 | }); |
| 154 | |
| 155 | it("refuses tokens and sessions at once, stops Actions, and unsuspending restores the tokens", async () => { |
| 156 | const { env, DB, stops, audits, audit, acct } = setup(); |
| 157 | const token = "dfy_test_token"; |
| 158 | const agentToken = "dfy_agent_token"; |
| 159 | for (const [id, owner, t] of [["t1", "bob", token], ["t2", "bot", agentToken]]) { |
| 160 | DB.sqlite.prepare("INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES (?, ?, 'x', ?, 'dfy', '[\"repo\"]', ?, ?)").run(id!, owner!, await sha256(t!), later, at); |
| 161 | } |
| 162 | DB.sqlite.prepare("INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('s1', 'bob', 'passkey', ?, ?, ?)").run(at, at, later); |
| 163 | const dir = new Directory(env); |
| 164 | const ann = await acct("ann"); |
| 165 | expect((await dir.verifyToken(token))?.account.login).toBe("bob"); |
| 166 | |
| 167 | const stopped = await suspend(env, audit, ann, await acct("bob"), "spam", "api"); |
| 168 | expect(stops).toEqual(["tool"]); |
| 169 | expect(stopped).toMatchObject({ repositories: 1 }); |
| 170 | expect(await dir.verifyToken(token)).toBeNull(); |
| 171 | expect(await dir.verifyToken(agentToken)).toBeNull(); |
| 172 | expect(DB.sqlite.prepare("SELECT count(*) AS n FROM sessions").get()).toEqual({ n: 0 }); |
| 173 | const bob = await acct("bob"); |
| 174 | expect(bob).toMatchObject({ status: "disabled", suspended_by: "ann", suspended_reason: "spam" }); |
| 175 | expect(blockedBy(await killSwitches(env, "tool"), "run")).toMatch(/spam/); |
| 176 | expect(audits[0]).toMatchObject({ action: "user.suspend", target: "bob", detail: { reason: "spam", via: "api" } }); |
| 177 | |
| 178 | expect(await unsuspend(env, audit, ann, bob, "web")).toBe(true); |
| 179 | expect(await unsuspend(env, audit, ann, await acct("bob"), "web")).toBe(false); |
| 180 | expect(await acct("bob")).toMatchObject({ status: "active", suspended_at: null }); |
| 181 | expect((await dir.verifyToken(token))?.account.login).toBe("bob"); |
| 182 | expect((await dir.verifyToken(agentToken))?.account.login).toBe("bot"); |
| 183 | expect(blockedBy(await killSwitches(env, "tool"), "run")).toBeNull(); |
| 184 | expect(audits[1]).toEqual({ actorId: "ann", actorLogin: "ann", action: "user.unsuspend", target: "bob", detail: { via: "web" } }); |
| 185 | expect(audits).toHaveLength(2); |
| 186 | }); |
| 187 | |
| 188 | it("can't suspend a site administrator, yourself, or an organization", async () => { |
| 189 | const { env, audit, acct } = setup(); |
| 190 | const [ann, bob, acme] = await Promise.all(["ann", "bob", "acme"].map(acct)); |
| 191 | await expect(suspend(env, audit, bob!, ann!, "x", "api")).rejects.toThrow(/site administrator/); |
| 192 | await expect(suspend(env, audit, ann!, ann!, "x", "api")).rejects.toThrow(/yourself/); |
| 193 | await expect(suspend(env, audit, ann!, acme!, "x", "api")).rejects.toThrow(/only people/); |
| 194 | }); |
| 195 | }); |
| 196 | |
| 197 | describe("hidden comments", () => { |
| 198 | it("take GitHub's classifiers and say why they are hidden", () => { |
| 199 | expect(minimizeReason("OFF_TOPIC")).toBe("off_topic"); |
| 200 | expect(minimizeReason("off-topic")).toBe("off_topic"); |
| 201 | expect(minimizeReason("spam")).toBe("spam"); |
| 202 | expect(() => minimizeReason("rude")).toThrow(/reason must be one of/); |
| 203 | expect(minimizedNote("duplicate")).toBe("This comment was marked as duplicate."); |
| 204 | expect(minimizedNote(null)).toBe("This comment has been minimized."); |
| 205 | }); |
| 206 | }); |