clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

dependabot/github_actions/actions-9b61906d8b
bindws / test / object / moderation.test.ts
moderation.test.ts 12.3K
1// Moderation: reports that hide an event until a moderator looks, the queue2// over NIP-86, a ban that erases, and the log every change leaves.3import { SELF } from "cloudflare:test";4import { describe, it, expect } from "vitest";5import { generateSecretKey, getPublicKey, type Event } from "nostr-tools/pure";6import { now, ev, rpc, pk } from "../helpers/relay.ts";7import { WS } from "../helpers/ws.ts";8import { upload } from "../helpers/media.ts";910async function publish(host: string, e: Event): Promise<{ ok: boolean; msg: string }> {11 const resp = await SELF.fetch(`http://${host}/`, { headers: { upgrade: "websocket" } });12 const ws = resp.webSocket!;13 ws.accept();14 return new Promise((res) => {15 ws.addEventListener("message", (m) => {16 const a = JSON.parse(m.data as string);17 if (a[0] === "OK") {18 ws.close();19 res({ ok: a[2], msg: a[3] });20 }21 });22 ws.send(JSON.stringify(["EVENT", e]));23 });24}2526type Row = { seq: number; at: number; actor: string; action: string; target: string; detail: string };2728describe("moderation queue", () => {29 it("files NIP-56 reports for the owner and resolves them with ban, delete or dismiss", async () => {30 const host = "mods.bind.ws";31 const owner = generateSecretKey();32 const troll = generateSecretKey();33 const reporter = generateSecretKey();34 await rpc(host, owner, "claim");35 const c = await WS.connect(host);36 const bad = ev(troll, 1, "something awful");37 await c.ok(bad);38 const report = ev(reporter, 1984, "please look", [["e", bad.id, "spam"], ["p", getPublicKey(troll)]]);39 expect((await c.ok(report)).msg).toBe("info: report received");40 expect(await c.req({ kinds: [1984] })).toEqual([]); // never served41 let q = (await rpc(host, owner, "listreports")).result;42 expect(q.length).toBe(1);43 expect(q[0]).toMatchObject({ id: report.id, target_event: bad.id, target_pubkey: getPublicKey(troll), type: "spam", status: "open" });44 expect((await rpc(host, owner, "resolvereport", report.id, "ban")).result).toBe(true);45 expect(await c.req({ ids: [bad.id] })).toEqual([]);46 expect((await c.ok(ev(troll, 1, "back"))).msg).toMatch(/^blocked:/);47 expect((await rpc(host, owner, "listreports")).result).toEqual([]);48 expect((await rpc(host, owner, "listreports", "resolved")).result[0].action).toBe("ban");49 });50});5152describe("report thresholds", () => {53 it("hides an event once enough people report it, until a moderator resolves the reports", async () => {54 const host = "threshold.bind.ws";55 const owner = generateSecretKey();56 const author = generateSecretKey();57 const r1 = generateSecretKey();58 const r2 = generateSecretKey();59 await rpc(host, owner, "claim");60 expect((await rpc(host, owner, "setpolicy", { reportThreshold: 2 })).result.reportThreshold).toBe(2);61 const c = await WS.connect(host);62 const note = ev(author, 1, "contested");63 expect((await c.ok(note)).ok).toBe(true);64 const report = (sk: Uint8Array) => ev(sk, 1984, "nope", [["e", note.id, "spam"], ["p", pk(author)]]);65 const firstReport = report(r1);66 expect((await c.ok(firstReport)).ok).toBe(true);67 expect((await c.ok(firstReport)).ok).toBe(true); // retry the same signed report across clock ticks68 expect((await c.req({ ids: [note.id] })).length).toBe(1);69 expect((await c.ok(report(r2))).ok).toBe(true);70 // Hidden everywhere stored events are read.71 expect(await c.req({ ids: [note.id] })).toEqual([]);72 expect((await c.count({ authors: [pk(author)] })).count).toBe(0);73 expect((await SELF.fetch(`http://${host}/e/${note.id}`)).status).toBe(404);74 const feed = await (await SELF.fetch(`http://${host}/feed.xml`)).text();75 expect(feed.includes(note.id)).toBe(false);76 const reports = (await rpc(host, owner, "listreports")).result;77 expect(reports.length).toBe(2);78 expect(reports.every((r: any) => r.hidden === 1)).toBe(true);79 expect((await rpc(host, owner, "listeventsneedingmoderation")).result.map((r: any) => r.id)).toEqual([note.id]);80 // Dismissing one report leaves the hold; dismissing the last lifts it.81 await rpc(host, owner, "resolvereport", reports[0].id, "dismiss");82 expect(await c.req({ ids: [note.id] })).toEqual([]);83 await rpc(host, owner, "resolvereport", reports[1].id, "dismiss");84 expect((await c.req({ ids: [note.id] })).length).toBe(1);85 // Reports on a pubkey alone never hide anything.86 expect((await c.ok(ev(r1, 1984, "person", [["p", pk(author), "impersonation"]]))).ok).toBe(true);87 expect((await c.ok(ev(r2, 1984, "person", [["p", pk(author), "impersonation"]]))).ok).toBe(true);88 expect((await c.req({ ids: [note.id] })).length).toBe(1);89 // Delete makes it permanent.90 const again = ev(author, 1, "contested again");91 expect((await c.ok(again)).ok).toBe(true);92 for (const sk of [r1, r2]) expect((await c.ok(ev(sk, 1984, "nope", [["e", again.id, "spam"], ["p", pk(author)]]))).ok).toBe(true);93 expect(await c.req({ ids: [again.id] })).toEqual([]);94 const open = (await rpc(host, owner, "listreports")).result.filter((r: any) => r.target_event === again.id);95 await rpc(host, owner, "resolvereport", open[0].id, "delete");96 expect((await c.ok(again)).msg).toMatch(/banned/);97 });98});99100describe("listeventsneedingmoderation", () => {101 it("lists open reports by reported thing, deduplicated, for owner and moderator only", async () => {102 const host = "modqueue.bind.ws";103 const owner = generateSecretKey();104 const mod = generateSecretKey();105 const member = generateSecretKey();106 const author = generateSecretKey();107 const reporter = generateSecretKey();108 await rpc(host, owner, "claim");109 await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });110 await rpc(host, owner, "setmember", pk(member), {});111 const c = (await WS.tryConnect(host, "10.9.0.1"))!;112 const note = ev(author, 1, "buy my coin");113 expect((await c.ok(note)).ok).toBe(true);114 // Two reports on the note, one on the author alone.115 expect((await c.ok(ev(reporter, 1984, "junk", [["e", note.id, "spam"], ["p", pk(author)]]))).ok).toBe(true);116 expect((await c.ok(ev(member, 1984, "", [["e", note.id, "spam"], ["p", pk(author)]]))).ok).toBe(true);117 expect((await c.ok(ev(reporter, 1984, "bot", [["p", pk(author), "impersonation"]]))).ok).toBe(true);118119 const list = await rpc(host, owner, "listeventsneedingmoderation");120 expect(list.status).toBe(200);121 expect(list.result).toEqual([{ id: note.id, reason: "spam: junk" }]);122 expect((await rpc(host, mod, "listeventsneedingmoderation")).result).toEqual([{ id: note.id, reason: "spam: junk" }]);123 expect((await rpc(host, member, "listeventsneedingmoderation")).status).toBe(403);124 expect((await rpc(host, owner, "supportedmethods")).result).toEqual(expect.arrayContaining(["listeventsneedingmoderation", "blockip", "unblockip", "listblockedips"]));125126 // Resolving the reports empties the list.127 const reports = (await rpc(host, owner, "listreports")).result as { id: string }[];128 for (const r of reports) await rpc(host, owner, "resolvereport", r.id, "dismiss");129 expect((await rpc(host, owner, "listeventsneedingmoderation")).result).toEqual([]);130 });131});132133describe("ban and erase", () => {134 it("removes everything a banned pubkey wrote and uploaded when asked", async () => {135 const host = "erase.bind.ws";136 const owner = generateSecretKey();137 const member = generateSecretKey();138 const other = generateSecretKey();139 await rpc(host, owner, "claim");140 await rpc(host, owner, "setmember", pk(member), {});141 await rpc(host, owner, "setmember", pk(other), {});142 const c = await WS.connect(host);143 for (const e of [ev(member, 0, JSON.stringify({ name: "soon gone" })), ev(member, 1, "one"), ev(member, 1, "two"), ev(other, 1, "stays")]) expect((await c.ok(e)).ok).toBe(true);144 const { sha } = await upload(host, member, "a file to erase");145 expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(200);146 // A plain ban keeps the history.147 expect((await rpc(host, owner, "banpubkey", pk(other), "plain")).result).toBe(true);148 expect((await c.req({ authors: [pk(other)] })).length).toBe(1);149 // A ban with erase does not.150 expect((await rpc(host, owner, "banpubkey", pk(member), "gone", true)).result).toBe(true);151 expect(await c.req({ authors: [pk(member)] })).toEqual([]);152 expect((await SELF.fetch(`http://${host}/${sha}`)).status).toBe(404);153 expect((await rpc(host, owner, "listblobs")).result).toEqual([]);154 expect((await rpc(host, owner, "listmembers")).result.members.some((m: any) => m.pubkey === pk(member))).toBe(false);155 expect((await c.ok(ev(member, 1, "back?"))).msg).toMatch(/banned/);156 // The same through a report.157 const reporter = generateSecretKey();158 await rpc(host, owner, "setpolicy", { writes: "open" });159 const bad = ev(generateSecretKey(), 1, "report me");160 expect((await c.ok(bad)).ok).toBe(true);161 expect((await c.ok(ev(reporter, 1984, "spam", [["e", bad.id, "spam"], ["p", bad.pubkey]]))).ok).toBe(true);162 const report = (await rpc(host, owner, "listreports")).result[0];163 expect((await rpc(host, owner, "resolvereport", report.id, "ban", true)).result).toBe(true);164 expect(await c.req({ authors: [bad.pubkey] })).toEqual([]);165 });166});167168describe("moderation log", () => {169 it("records what changed, who did it and to whom, and reads back newest first", async () => {170 const host = "auditlog.bind.ws";171 const owner = generateSecretKey();172 const mod = generateSecretKey();173 const member = generateSecretKey();174 const spammer = generateSecretKey();175 await rpc(host, owner, "claim");176 await rpc(host, owner, "setmember", pk(mod), { role: "moderator" });177 await rpc(host, owner, "setmember", pk(member), {});178 expect((await rpc(host, owner, "getpolicy")).status).toBe(200);179 expect((await rpc(host, mod, "banpubkey", pk(spammer), "spam")).status).toBe(200);180 expect((await rpc(host, owner, "blockip", "203.0.113.9", "swarm")).status).toBe(200);181 expect((await rpc(host, owner, "setblockedwords", ["casino"])).status).toBe(200);182 expect((await rpc(host, owner, "setpolicy", { reads: "auth", maxSubs: 5 })).status).toBe(200);183 // A refused call leaves nothing.184 expect((await rpc(host, mod, "setpolicy", { reads: "open" })).status).toBe(403);185 expect((await rpc(host, owner, "banpubkey", "zz", "")).status).toBe(400);186187 const rows = (await rpc(host, owner, "listaudit")).result as Row[];188 const actions = rows.map((r) => [r.actor.slice(0, 8), r.action, r.target]);189 expect(actions.slice(0, 4)).toEqual([190 [pk(owner).slice(0, 8), "setpolicy", ""],191 [pk(owner).slice(0, 8), "setblockedwords", ""],192 [pk(owner).slice(0, 8), "blockip", "203.0.113.9"],193 [pk(mod).slice(0, 8), "banpubkey", pk(spammer)],194 ]);195 expect(rows[0].detail).toBe('["reads","maxSubs"]');196 expect(rows[1].detail).toBe('["casino"]');197 expect(rows[2].detail).toBe('"swarm"');198 expect(rows.map((r) => r.action)).not.toContain("getpolicy");199 expect(rows.map((r) => r.action)).toContain("setmember");200 for (const r of rows) expect(r.at).toBeGreaterThan(now() - 60);201 // seq descends; paging with `before` continues from a row.202 expect(rows[0].seq).toBeGreaterThan(rows[1].seq);203 const older = (await rpc(host, owner, "listaudit", rows[1].seq)).result as Row[];204 expect(older[0].seq).toBe(rows[2].seq);205 // Moderators read it, members do not, and reading is not itself logged.206 expect((await rpc(host, mod, "listaudit")).status).toBe(200);207 expect((await rpc(host, member, "listaudit")).status).toBe(403);208 expect(((await rpc(host, owner, "listaudit")).result as Row[])[0].action).toBe("setpolicy");209 });210211 it("records NIP-29 moderation events under their names", async () => {212 const host = "auditgroup.bind.ws";213 const owner = generateSecretKey();214 const someone = generateSecretKey();215 await rpc(host, owner, "claim");216 const put = ev(owner, 9000, "", [["h", host.split(".")[0]], ["p", pk(someone), "member"]]);217 expect((await publish(host, put)).ok).toBe(true);218 const remove = ev(owner, 9001, "", [["h", host.split(".")[0]], ["p", pk(someone)]]);219 expect((await publish(host, remove)).ok).toBe(true);220 const rows = (await rpc(host, owner, "listaudit")).result as Row[];221 expect(rows.slice(0, 2).map((r) => [r.action, r.target, r.actor])).toEqual([222 ["remove-user", pk(someone), pk(owner)],223 ["put-user", pk(someone), pk(owner)],224 ]);225 });226});