Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

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