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.

307 lines · 18.9 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 { spawn, execFileSync } from "node:child_process";
12import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
13import { mkdtempSync, rmSync } from "node:fs";
14import { tmpdir } from "node:os";
15import { join, dirname, resolve } from "node:path";
16import { fileURLToPath } from "node:url";
17import { createServer } from "node:net";
18import { test, before, after } from "node:test";
19import assert from "node:assert/strict";
20
21const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
22const work = mkdtempSync(join(tmpdir(), "diffinery-moderation-"));
23let base;
24let dev;
25let devLog = "";
26
27const hash = (s) => createHash("sha256").update(s).digest("hex");
28const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`;
29const T = { olive: tok("olive"), sully: tok("sully"), rita: tok("rita"), mal: tok("mal"), sid: tok("sid"), sam: tok("sam") };
30const 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
32const 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 });
40const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
41
42/** Call the REST API as a token, a session cookie (`{ cookie }`) or anonymously (null). */
43async 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}
58async 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}
63async 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}
68const b64 = (s) => Buffer.from(s).toString("base64");
69
70/** A web page, signed in with `session` (or anonymously). */
71async 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`. */
77async 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
87before(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
126after(() => {
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). */
136async 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. */
144let made;
145const 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
155test("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
189test("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
232test("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&#39;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
281test("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. */
302async 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}