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.

206 lines · 11.9 KBRaw History
1import { describe, expect, it } from "vitest";
2import {
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";
23import { Directory, type Account } from "../../src/lib/directory.js";
24import { blockedBy, killSwitches } from "../../src/lib/killswitch.js";
25import { sha256 } from "../../src/lib/util.js";
26import type { Env } from "../../src/env.js";
27import { testD1 } from "./stubs/d1.js";
28
29const at = "2026-10-01T00:00:00.000Z";
30const 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. */
33function 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
55const target = { kind: "issue" as const, target: "acme/app#1", url: "https://x/acme/app/issues/1", accountId: "bob", repoId: "app", excerpt: "buy now" };
56
57describe("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
93describe("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
144describe("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
197describe("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});