| 1 | // End-to-end checks of moderation (lib/moderation.ts) against a real `wrangler dev` Worker with |
| 2 | // seeded accounts, tokens and sessions: |
| 3 | // |
| 4 | // flock /home/claude/e2e.lock timeout 1500 node --test --test-concurrency=1 --test-timeout=900000 test/e2e/moderation.test.mjs |
| 5 | // |
| 6 | // Covers: reports (REST and the web form) land in the site administrators' queue and are resolved; |
| 7 | // a blocked person can't open issues, comment, react or be added as a collaborator in the blocker's |
| 8 | // (or the blocking organization's) repositories but still can everywhere else; a suspended person's |
| 9 | // token and session stop at once, their repositories, profile and comments disappear for everyone |
| 10 | // but site administrators, and unsuspending restores it all; a minimized comment shows collapsed. |
| 11 | import { spawn, execFileSync } from "node:child_process"; |
| 12 | import { createHash, generateKeyPairSync, randomBytes } from "node:crypto"; |
| 13 | import { mkdtempSync, rmSync } from "node:fs"; |
| 14 | import { tmpdir } from "node:os"; |
| 15 | import { join, dirname, resolve } from "node:path"; |
| 16 | import { fileURLToPath } from "node:url"; |
| 17 | import { createServer } from "node:net"; |
| 18 | import { test, before, after } from "node:test"; |
| 19 | import assert from "node:assert/strict"; |
| 20 | |
| 21 | const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 22 | const work = mkdtempSync(join(tmpdir(), "diffinery-moderation-")); |
| 23 | let base; |
| 24 | let dev; |
| 25 | let devLog = ""; |
| 26 | |
| 27 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 28 | const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`; |
| 29 | const T = { olive: tok("olive"), sully: tok("sully"), rita: tok("rita"), mal: tok("mal"), sid: tok("sid"), sam: tok("sam") }; |
| 30 | const S = { olive: `olive-${randomBytes(12).toString("hex")}`, sully: `sully-${randomBytes(12).toString("hex")}`, rita: `rita-${randomBytes(12).toString("hex")}`, sid: `sid-${randomBytes(12).toString("hex")}` }; |
| 31 | |
| 32 | const freePort = () => |
| 33 | new Promise((res) => { |
| 34 | const s = createServer(); |
| 35 | s.listen(0, "127.0.0.1", () => { |
| 36 | const { port } = s.address(); |
| 37 | s.close(() => res(port)); |
| 38 | }); |
| 39 | }); |
| 40 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 41 | |
| 42 | /** Call the REST API as a token, a session cookie (`{ cookie }`) or anonymously (null). */ |
| 43 | async function api(method, path, body, as = T.olive) { |
| 44 | const headers = { "content-type": "application/json" }; |
| 45 | if (typeof as === "string") headers.authorization = `Bearer ${as}`; |
| 46 | else if (as?.cookie) headers.cookie = `__Host-dfy_session=${as.cookie}`; |
| 47 | const send = () => fetch(`${base}/api/v3${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); |
| 48 | const res = await send().catch((e) => (e.cause?.code === "UND_ERR_SOCKET" ? send() : Promise.reject(e))); |
| 49 | const text = await res.text(); |
| 50 | let json = null; |
| 51 | try { |
| 52 | json = text ? JSON.parse(text) : null; |
| 53 | } catch { |
| 54 | json = text; |
| 55 | } |
| 56 | return { status: res.status, json }; |
| 57 | } |
| 58 | async function ok(method, path, body, as) { |
| 59 | const r = await api(method, path, body, as); |
| 60 | assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); |
| 61 | return r.json; |
| 62 | } |
| 63 | async function status(expected, method, path, body, as) { |
| 64 | const r = await api(method, path, body, as); |
| 65 | assert.equal(r.status, expected, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); |
| 66 | return r.json; |
| 67 | } |
| 68 | const b64 = (s) => Buffer.from(s).toString("base64"); |
| 69 | |
| 70 | /** A web page, signed in with `session` (or anonymously). */ |
| 71 | async function page(path, session) { |
| 72 | const r = await fetch(`${base}${path}`, { headers: session ? { cookie: `__Host-dfy_session=${session}` } : {}, redirect: "manual" }); |
| 73 | return { status: r.status, html: await r.text() }; |
| 74 | } |
| 75 | |
| 76 | /** A web form post signed in with `session`, with the CSRF pair taken from `from`. */ |
| 77 | async function submit(session, from, action, fields) { |
| 78 | const cookie = `__Host-dfy_session=${session}`; |
| 79 | const res = await fetch(`${base}${from}`, { headers: { cookie } }); |
| 80 | const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1]; |
| 81 | const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1]; |
| 82 | assert.ok(csrf && pair, `no CSRF pair on ${from} (${res.status})`); |
| 83 | const r = await fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${pair}`, "sec-fetch-site": "same-origin" } }); |
| 84 | return { status: r.status, location: r.headers.get("location"), flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")) }; |
| 85 | } |
| 86 | |
| 87 | before(async () => { |
| 88 | const port = await freePort(); |
| 89 | base = `http://127.0.0.1:${port}`; |
| 90 | const persist = join(work, "state"); |
| 91 | const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 92 | wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist); |
| 93 | const at = new Date().toISOString(); |
| 94 | const later = new Date(Date.now() + 30 * 86400_000).toISOString(); |
| 95 | const account = (id, login, kind) => `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('${id}', '${login}', '${kind}', '${login}', 'active', '${at}')`; |
| 96 | const token = (id, account, t) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '["admin"]', '${later}', '${at}')`; |
| 97 | const session = (account, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${at}', '${at}', '${later}')`; |
| 98 | wrangler( |
| 99 | "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", |
| 100 | [ |
| 101 | ...["olive", "sully", "rita", "mal", "sid", "sam"].map((l) => account(`u-${l}`, l, "user")), |
| 102 | "UPDATE accounts SET site_admin = 1 WHERE id = 'u-sully'", |
| 103 | ...Object.entries(T).map(([l, t]) => token(`t-${l}`, `u-${l}`, t)), |
| 104 | ...Object.entries(S).map(([l, s]) => session(`u-${l}`, s)), |
| 105 | account("o-guild", "guild", "org"), |
| 106 | `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-olive', 'owner', '${at}')`, |
| 107 | ].join("; "), |
| 108 | ); |
| 109 | dev = spawn( |
| 110 | "pnpm", |
| 111 | ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--var", `OIDC_PRIVATE_KEY:${JSON.stringify(generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "jwk" }))}`, "--show-interactive-dev-session=false"], |
| 112 | { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, |
| 113 | ); |
| 114 | dev.stdout.on("data", (d) => (devLog += d)); |
| 115 | dev.stderr.on("data", (d) => (devLog += d)); |
| 116 | const end = Date.now() + 120_000; |
| 117 | while (Date.now() < end) { |
| 118 | try { |
| 119 | if ((await fetch(`${base}/healthz`)).ok) return; |
| 120 | } catch {} |
| 121 | await sleep(300); |
| 122 | } |
| 123 | assert.fail(`wrangler dev didn't start\n${devLog.slice(-4000)}`); |
| 124 | }); |
| 125 | |
| 126 | after(() => { |
| 127 | if (dev?.pid) { |
| 128 | try { |
| 129 | process.kill(-dev.pid, "SIGTERM"); |
| 130 | } catch {} |
| 131 | } |
| 132 | if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
| 133 | }); |
| 134 | |
| 135 | /** A public repository with one commit, created by `as` (under `owner`, a person or an organization). */ |
| 136 | async function publicRepo(owner, name, as) { |
| 137 | await ok("POST", owner === "user" ? "/user/repos" : `/orgs/${owner}/repos`, { name, visibility: "public" }, as); |
| 138 | const login = owner === "user" ? (await ok("GET", "/user", undefined, as)).login : owner; |
| 139 | await ok("PUT", `/repos/${login}/${name}/contents/README.md`, { message: "init", content: b64(`# ${name}\n`) }, as); |
| 140 | return `/repos/${login}/${name}`; |
| 141 | } |
| 142 | |
| 143 | /** olive's public repository town with issue #1, guild's hall, sam's park and sid's pub, made once. */ |
| 144 | let made; |
| 145 | const setup = () => |
| 146 | (made ??= (async () => { |
| 147 | const town = await publicRepo("user", "town", T.olive); |
| 148 | const hall = await publicRepo("guild", "hall", T.olive); |
| 149 | const park = await publicRepo("user", "park", T.sam); |
| 150 | const pub = await publicRepo("user", "pub", T.sid); |
| 151 | const welcome = await ok("POST", `${town}/issues`, { title: "Welcome" }); |
| 152 | return { town, hall, park, pub, welcome }; |
| 153 | })()); |
| 154 | |
| 155 | test("moderation: reports land in the site administrators' queue", async () => { |
| 156 | const { welcome } = await setup(); |
| 157 | const filed = await status(201, "POST", "/diffinery/reports", { kind: "issue", repository: "olive/town", number: welcome.number, reason: "spam", body: "looks like an ad" }, T.rita); |
| 158 | assert.equal(filed.target, `olive/town#${welcome.number}`); |
| 159 | assert.equal(filed.state, "open"); |
| 160 | assert.match(filed.html_url, /\/olive\/town\/issues\/1$/); |
| 161 | // A reason from the list, a bounded note, one open report per thing. |
| 162 | await status(422, "POST", "/diffinery/reports", { kind: "issue", repository: "olive/town", number: welcome.number, reason: "boring" }, T.rita); |
| 163 | await status(422, "POST", "/diffinery/reports", { kind: "user", login: "mal", reason: "spam", body: "x".repeat(2001) }, T.rita); |
| 164 | await status(422, "POST", "/diffinery/reports", { kind: "issue", repository: "olive/town", number: welcome.number, reason: "spam" }, T.rita); |
| 165 | await status(401, "POST", "/diffinery/reports", { kind: "user", login: "mal", reason: "spam" }, null); |
| 166 | // The web form files one too. |
| 167 | const form = await submit(S.rita, "/-/report?kind=user&login=mal", "/-/report?kind=user&login=mal", { reason: "harassment", body: "keeps pinging me" }); |
| 168 | assert.ok([302, 303].includes(form.status), String(form.status)); |
| 169 | assert.match(form.flash, /Thanks for reporting it/); |
| 170 | |
| 171 | await status(403, "GET", "/admin/reports", undefined, T.rita); |
| 172 | const queue = await ok("GET", "/admin/reports", undefined, T.sully); |
| 173 | assert.deepEqual(queue.map((r) => [r.kind, r.target, r.reason, r.reporter]).sort(), [ |
| 174 | ["issue", "olive/town#1", "spam", "rita"], |
| 175 | ["user", "mal", "harassment", "rita"], |
| 176 | ]); |
| 177 | const adminPage = await page("/settings/admin/reports", S.sully); |
| 178 | assert.equal(adminPage.status, 200); |
| 179 | assert.match(adminPage.html, /olive\/town#1/); |
| 180 | assert.match(adminPage.html, /keeps pinging me|harassment|Harassment/); |
| 181 | assert.notEqual((await page("/settings/admin/reports", S.rita)).status, 200); |
| 182 | const done = await ok("PATCH", `/admin/reports/${filed.id}`, { state: "resolved", resolution: "spoke to olive" }, T.sully); |
| 183 | assert.equal(done.state, "resolved"); |
| 184 | assert.equal(done.resolution, "spoke to olive"); |
| 185 | assert.deepEqual((await ok("GET", "/admin/reports", undefined, T.sully)).map((r) => r.target), ["mal"]); |
| 186 | |
| 187 | }); |
| 188 | |
| 189 | test("moderation: a blocked person can't take part in the blocker's repositories, and can elsewhere", async () => { |
| 190 | const { town, hall, park, welcome } = await setup(); |
| 191 | await collaborator(town, "mal", T.mal); |
| 192 | const before = await ok("POST", `${town}/issues/${welcome.number}/comments`, { body: "hi from mal" }, T.mal); |
| 193 | await status(204, "PUT", "/user/blocks/mal"); |
| 194 | await status(204, "GET", "/user/blocks/mal"); |
| 195 | assert.deepEqual((await ok("GET", "/user/blocks")).map((u) => u.login), ["mal"]); |
| 196 | await status(404, "GET", "/user/blocks/rita"); |
| 197 | await status(422, "PUT", "/user/blocks/olive"); |
| 198 | // Blocking removed mal as a collaborator, and they can't be added back. |
| 199 | assert.ok(!(await ok("GET", `${town}/collaborators`)).some((u) => u.login === "mal")); |
| 200 | await status(422, "PUT", `${town}/collaborators/mal`, { permission: "write" }); |
| 201 | |
| 202 | const refused = await status(403, "POST", `${town}/issues`, { title: "let me in" }, T.mal); |
| 203 | assert.match(refused.message, /olive blocked you/); |
| 204 | await status(403, "POST", `${town}/issues/${welcome.number}/comments`, { body: "again" }, T.mal); |
| 205 | await status(403, "POST", `${town}/issues/${welcome.number}/reactions`, { content: "+1" }, T.mal); |
| 206 | await status(403, "POST", `${town}/pulls`, { title: "x", head: "main", base: "main" }, T.mal); |
| 207 | // Reading stays public, and what they wrote before stays. |
| 208 | await ok("GET", `${town}/issues/${welcome.number}`, undefined, T.mal); |
| 209 | assert.ok((await ok("GET", `${town}/issues/${welcome.number}/comments`)).some((c) => c.id === before.id)); |
| 210 | // Everywhere else mal takes part as before: sam's repository, and a pull request where mal can write. |
| 211 | const elsewhere = await status(201, "POST", `${park}/issues`, { title: "mal's issue" }, T.mal); |
| 212 | await status(201, "POST", `${park}/issues/${elsewhere.number}/comments`, { body: "and a comment" }, T.mal); |
| 213 | await collaborator(park, "mal", T.mal, T.sam); |
| 214 | await ok("POST", `${park}/git/refs`, { ref: "refs/heads/mal-change", sha: (await ok("GET", `${park}/git/ref/heads/main`, undefined, T.sam)).object.sha }, T.mal); |
| 215 | await ok("PUT", `${park}/contents/NOTES.md`, { message: "notes", content: b64("notes\n"), branch: "mal-change" }, T.mal); |
| 216 | await status(201, "POST", `${park}/pulls`, { title: "mal's change", head: "mal-change", base: "main" }, T.mal); |
| 217 | |
| 218 | // An organization blocks through its owners, with the same effect in its repositories. |
| 219 | await status(201, "POST", `${hall}/issues`, { title: "before the block" }, T.mal); |
| 220 | await status(404, "PUT", "/orgs/guild/blocks/mal", undefined, T.rita); |
| 221 | await status(204, "PUT", "/orgs/guild/blocks/mal"); |
| 222 | await status(204, "GET", "/orgs/guild/blocks/mal"); |
| 223 | assert.match((await status(403, "POST", `${hall}/issues`, { title: "after the block" }, T.mal)).message, /guild blocked you/); |
| 224 | // Unblocking lets them back in. |
| 225 | await status(204, "DELETE", "/user/blocks/mal"); |
| 226 | await status(204, "DELETE", "/orgs/guild/blocks/mal"); |
| 227 | await status(201, "POST", `${town}/issues`, { title: "thanks" }, T.mal); |
| 228 | await status(201, "POST", `${hall}/issues`, { title: "thanks" }, T.mal); |
| 229 | |
| 230 | }); |
| 231 | |
| 232 | test("moderation: a suspended person is stopped and hidden at once, and unsuspending restores them", async () => { |
| 233 | const { town, park, pub, welcome } = await setup(); |
| 234 | const sidIssue = await status(201, "POST", `${town}/issues`, { title: "sid was here" }, T.sid); |
| 235 | const sidComment = await status(201, "POST", `${town}/issues/${welcome.number}/comments`, { body: "sid's comment" }, T.sid); |
| 236 | await ok("PATCH", `${town}/issues/${sidIssue.number}`, { state: "closed" }); |
| 237 | const closedSid = (await ok("GET", `${town}/issues/events`)).find((e) => e.issue?.number === sidIssue.number); |
| 238 | assert.ok(closedSid, "closing sid's issue is an event"); |
| 239 | await ok("GET", "/user", undefined, T.sid); |
| 240 | await ok("GET", pub, undefined, T.olive); |
| 241 | assert.equal((await page("/sid", S.olive)).status, 200); |
| 242 | |
| 243 | await status(403, "PUT", "/users/sid/suspended", { reason: "spam" }, T.olive); |
| 244 | await status(422, "PUT", "/users/sid/suspended", {}, T.sully); |
| 245 | await status(204, "PUT", "/users/sid/suspended", { reason: "spam campaign" }, T.sully); |
| 246 | await status(401, "GET", "/user", undefined, T.sid); |
| 247 | await status(401, "GET", "/user", undefined, { cookie: S.sid }); |
| 248 | await status(401, "POST", `${park}/issues`, { title: "still here?" }, T.sid); |
| 249 | // Their repository, profile, issues and comments are gone for everyone else... |
| 250 | await status(404, "GET", pub, undefined, T.olive); |
| 251 | await status(404, "GET", pub, undefined, null); |
| 252 | assert.equal((await page("/sid/pub", S.olive)).status, 404); |
| 253 | assert.equal((await page("/sid", S.olive)).status, 404); |
| 254 | await status(404, "GET", "/users/sid", undefined, T.olive); |
| 255 | await status(404, "GET", `${town}/issues/${sidIssue.number}`, undefined, T.olive); |
| 256 | assert.ok(!(await ok("GET", `${town}/issues?state=all`)).some((i) => i.number === sidIssue.number)); |
| 257 | // The events endpoints embed the issue, so its events are hidden too. |
| 258 | assert.ok(!(await ok("GET", `${town}/issues/events`)).some((e) => e.issue?.number === sidIssue.number)); |
| 259 | await status(404, "GET", `${town}/issues/events/${closedSid.id}`); |
| 260 | await status(404, "GET", `${town}/issues/${sidIssue.number}/events`); |
| 261 | assert.ok(!(await ok("GET", `${town}/issues/${welcome.number}/comments`)).some((c) => c.id === sidComment.id)); |
| 262 | assert.doesNotMatch((await page(`/olive/town/issues/${welcome.number}`, S.olive)).html, /sid's comment|sid's comment/); |
| 263 | // ...but not for site administrators, who see why. |
| 264 | await ok("GET", pub, undefined, T.sully); |
| 265 | const shown = await ok("GET", "/users/sid", undefined, T.sully); |
| 266 | assert.equal(shown.suspension_reason, "spam campaign"); |
| 267 | assert.ok(shown.suspended_at); |
| 268 | assert.ok((await ok("GET", `${town}/issues/${welcome.number}/comments`, undefined, T.sully)).some((c) => c.id === sidComment.id)); |
| 269 | |
| 270 | // Unsuspending restores everything, tokens included. |
| 271 | await status(204, "DELETE", "/users/sid/suspended", undefined, T.sully); |
| 272 | assert.equal((await ok("GET", "/user", undefined, T.sid)).login, "sid"); |
| 273 | await ok("GET", pub, undefined, null); |
| 274 | assert.equal((await page("/sid", S.olive)).status, 200); |
| 275 | await ok("GET", `${town}/issues/${sidIssue.number}`); |
| 276 | assert.ok((await ok("GET", `${town}/issues/${welcome.number}/comments`)).some((c) => c.id === sidComment.id)); |
| 277 | assert.equal((await ok("GET", "/users/sid", undefined, T.sully)).suspended_at, null); |
| 278 | |
| 279 | }); |
| 280 | |
| 281 | test("moderation: a minimized comment shows collapsed", async () => { |
| 282 | const { town, welcome } = await setup(); |
| 283 | const spam = await status(201, "POST", `${town}/issues/${welcome.number}/comments`, { body: "BUY CHEAP WATCHES" }, T.rita); |
| 284 | // Only people who can write to the repository hide comments. |
| 285 | await status(403, "PUT", `/repos/olive/town/diffinery/comments/${spam.id}/minimized`, { reason: "spam" }, T.rita); |
| 286 | await status(422, "PUT", `/repos/olive/town/diffinery/comments/${spam.id}/minimized`, { reason: "rude" }); |
| 287 | assert.deepEqual(await ok("PUT", `/repos/olive/town/diffinery/comments/${spam.id}/minimized`, { classifier: "SPAM" }), { is_minimized: true, minimized_reason: "spam" }); |
| 288 | const issuePage = await page(`/olive/town/issues/${welcome.number}`, S.rita); |
| 289 | assert.match(issuePage.html, /<details class="minimized" data-minimized="spam">/); |
| 290 | assert.match(issuePage.html, /This comment was marked as spam\./); |
| 291 | // Collapsed, not deleted: the body is still there behind "Show comment". |
| 292 | assert.match(issuePage.html, /BUY CHEAP WATCHES/); |
| 293 | // From the web too: unhide, then hide as off-topic. |
| 294 | const issuePath = `/olive/town/issues/${welcome.number}`; |
| 295 | assert.ok([302, 303].includes((await submit(S.olive, issuePath, `/olive/town/comments/${spam.id}/unminimize`, {})).status)); |
| 296 | assert.doesNotMatch((await page(issuePath, S.rita)).html, /data-minimized=/); |
| 297 | assert.ok([302, 303].includes((await submit(S.olive, issuePath, `/olive/town/comments/${spam.id}/minimize`, { reason: "off_topic" })).status)); |
| 298 | assert.match((await page(issuePath, S.rita)).html, /This comment was marked as off-topic\./); |
| 299 | }); |
| 300 | |
| 301 | /** Make `login` a collaborator on `repo` with write access, accepting the invitation as them. */ |
| 302 | async function collaborator(repo, login, invitee, as = T.olive) { |
| 303 | const r = await api("PUT", `${repo}/collaborators/${login}`, { permission: "write" }, as); |
| 304 | if (r.status === 204) return; |
| 305 | assert.equal(r.status, 201, `PUT ${repo}/collaborators/${login} -> ${r.status} ${JSON.stringify(r.json)}`); |
| 306 | await status(204, "PATCH", `/user/repository_invitations/${r.json.id}`, undefined, invitee); |
| 307 | } |