// Cases (docs/console.md): reports, rating reviews, deletion and removal requests, appeals; their threads, who sees // and decides them, what deciding does; and notifications. import { afterAll, beforeAll, expect, test } from "bun:test"; import { openDatabase, type Database } from "@t5de/db"; import { createApp } from "../src/app"; import { createAuth } from "../src/auth"; import { ImvuClient, type ImvuUser } from "../src/imvu"; const webURL = "http://web.test"; const apiURL = "http://api.test"; const accounts = new Map(); const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => { const url = new URL(String(input)); if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 }); const u = [...accounts.values()].find( (u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(), ); return Response.json({ denormalized: u ? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } } : {}, }); }) as typeof fetch; // Catalog addresses that answer /health (for address changes). const healthy = new Set(["https://moved.example.test"]); let db: Database; let close: () => Promise; let base: string; let stop: () => void; beforeAll(async () => { ({ db, close } = await openDatabase("memory")); const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false }); const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU), apiURL, webURL, catalogHealthy: async (url) => healthy.has(url), }); hub.app.listen({ port: 0, hostname: "127.0.0.1" }); const server = hub.app.server as unknown as { port: number; stop: () => void }; stop = () => server.stop(); base = `http://127.0.0.1:${server.port}`; }); afterAll(async () => { stop(); await close(); }); type Person = { cookie: string; name: string }; async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record = {}) { const res = await fetch(base + path, { method, headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers }, body: body !== undefined ? JSON.stringify(body) : undefined, }); const text = await res.text(); let json: any; try { json = JSON.parse(text); } catch { json = text; } return { status: res.status, json }; } async function person(name: string): Promise { const res = await fetch(base + "/api/auth/sign-up/email", { method: "POST", headers: { Origin: webURL, "Content-Type": "application/json" }, body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }), }); return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name }; } const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n }); /** A person's catalog, registered and approved; its key's Authorization header. */ async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") { const reg = await call(owner, "POST", "/api/v1/catalogs", { name, url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`, contact: "takedowns@example.test", visibility: "public", membership, }); expect(reg.status).toBe(201); const id = reg.json.catalog.id as string; expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200); return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } }; } /** A product uploaded by a person to a catalog; its ID. */ async function upload(maker: Person, c: { id: string; auth: Record }, name: string, n: number, rating = "general") { const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id }); expect(tok.status).toBe(200); const r = await call( undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" }, c.auth, ); expect(r.status).toBe(201); expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200); return r.json.product as string; } let admin: Person; let mod: Person; const notes = async (p: Person) => (await call(p, "GET", "/api/v1/notifications")).json; test("the first account is the admin; a moderator", async () => { admin = await person("admin1"); mod = await person("warden"); const id = (await call(mod, "GET", "/api/v1/me")).json.person.id; expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "moderator" })).status).toBe(200); }); test("reports: anyone signed in reports; the hosting catalog's team and moderators decide; who reported stays private", async () => { const owner = await person("shelfowner"); const maker = await person("maker"); const reporter = await person("reporter"); const outsider = await person("outsider"); const c = await catalogOf(owner, admin, "Report Shelf"); const hat = await upload(maker, c, "Odd Hat", 1); expect((await call(undefined, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "x" })).status).toBe(401); expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: " " })).json.error).toBe("reason_needed"); const made = await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "Copied from another creator" }); expect(made.status).toBe(201); const id = made.json.case.id; expect(made.json.case).toMatchObject({ kind: "report", status: "open", tags: { product: hat }, tagNames: { [hat]: "Odd Hat" } }); expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "Again" })).json.error).toBe("already_reported"); expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { person: "reporter" }, reason: "Me" })).status).toBe(400); // Who sees it: the reporter, the hosting catalog's team, moderators. Not its creator, not others. const ids = async (p: Person) => (await call(p, "GET", "/api/v1/cases")).json.cases.map((x: any) => x.id); expect(await ids(reporter)).toEqual([id]); expect(await ids(owner)).toContain(id); expect(await ids(mod)).toContain(id); expect(await ids(maker)).not.toContain(id); expect((await call(maker, "GET", `/api/v1/cases/${id}`)).status).toBe(404); expect((await call(outsider, "GET", `/api/v1/cases/${id}`)).status).toBe(404); expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json).toMatchObject({ canDecide: true, canAssign: false }); expect((await call(reporter, "GET", `/api/v1/cases/${id}`)).json).toMatchObject({ canDecide: false, case: { openedBy: "reporter" } }); // The catalog's team decides it without knowing who reported; moderators know. expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json.case.openedBy).toBe(""); expect((await call(mod, "GET", `/api/v1/cases/${id}`)).json.case.openedBy).toBe("reporter"); expect((await call(owner, "GET", "/api/v1/console")).json.todo.cases).toBe(1); expect((await call(owner, "GET", "/api/v1/console")).json.sections).toContain("cases"); // A thread: replies reach the reporter; internal notes stay with those deciding. expect((await call(owner, "POST", `/api/v1/cases/${id}/notes`, { body: "Looking into it" })).status).toBe(200); expect((await call(owner, "POST", `/api/v1/cases/${id}/notes`, { body: "Probably fine", internal: true })).status).toBe(200); expect((await call(reporter, "GET", `/api/v1/cases/${id}`)).json.notes.map((n: any) => n.body)).toEqual(["Looking into it"]); expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json.notes.map((n: any) => [n.body, n.internal])).toEqual([ ["Looking into it", false], ["Probably fine", true], ]); expect((await notes(reporter)).notifications[0]).toMatchObject({ type: "case_note", data: { case: id, by: "shelfowner" } }); // Assigning is for moderators, to moderators. expect((await call(owner, "POST", `/api/v1/cases/${id}/assign`, { to: "shelfowner" })).status).toBe(403); expect((await call(admin, "POST", `/api/v1/cases/${id}/assign`, { to: "outsider" })).json.error).toBe("invalid"); expect((await call(admin, "POST", `/api/v1/cases/${id}/assign`, { to: "warden" })).json.case.assignee).toBe("warden"); // Deciding: the catalog's owner declines it; the reporter is told; it's done. expect((await call(outsider, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "" })).status).toBe(404); const decided = await call(owner, "POST", `/api/v1/cases/${id}/decide`, { decision: "decline", resolution: "It's an original." }); expect(decided.json.case).toMatchObject({ status: "declined", resolution: "It's an original.", decidedBy: "shelfowner" }); expect((await call(owner, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "" })).json.error).toBe("decided"); expect((await notes(reporter)).notifications[0]).toMatchObject({ type: "case_decided", data: { case: id, status: "declined" } }); expect((await call(reporter, "GET", "/api/v1/me")).json.person.unread).toBe(2); expect((await call(reporter, "POST", "/api/v1/notifications/read", {})).json.unread).toBe(0); // The audit log keeps who reported out of what the product's creator can read. const audit = (await call(maker, "GET", `/api/v1/console/audit?product=${hat}`)).json.entries.map((e: any) => e.action); expect(audit).not.toContain("case.open"); // Reports are limited: ten an hour. for (let i = 0; i < 9; i++) { const p = await person(`spam${i}`); expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { person: p.name }, reason: "Spam" })).status).toBe(201); } expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { catalog: c.id }, reason: "One more" })).status).toBe(429); }); test("rating reviews: a creator lowering a rating asks moderators; resolving lowers it", async () => { const owner = await person("reviewshelf"); const maker = await person("rater"); const c = await catalogOf(owner, admin, "Review Shelf"); const top = await upload(maker, c, "Plain Top", 2); expect((await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "adult" })).json.review).toBeNull(); // Lowering it opens a case (one at a time); other fields in the same edit still change. const asked = await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "general", name: "Plain Tee", reason: "Nothing adult about it" }); expect(asked.status).toBe(200); expect(asked.json.product).toMatchObject({ rating: "adult", name: "Plain Tee" }); const review = asked.json.review as number; expect(review).toBeGreaterThan(0); expect((await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "general" })).json.review).toBe(review); const kase = (await call(maker, "GET", `/api/v1/cases/${review}`)).json; expect(kase.case).toMatchObject({ kind: "rating-review", reason: "Nothing adult about it", openedBy: "rater" }); expect(kase.canDecide).toBe(false); // Its catalog's team doesn't see or decide ratings; moderators do. expect((await call(owner, "POST", `/api/v1/cases/${review}/decide`, { decision: "resolve", resolution: "" })).status).toBe(404); expect((await call(mod, "POST", `/api/v1/cases/${review}/decide`, { decision: "resolve", resolution: "Agreed" })).json.case.status).toBe("resolved"); const history = (await call(maker, "GET", `/api/v1/products/${top}/history`)).json.entries; expect(history[0]).toMatchObject({ by: "warden", reason: "Agreed", changes: { rating: { from: "adult", to: "general" } } }); const types = (await notes(maker)).notifications.map((n: any) => n.type); expect(types).toContain("case_decided"); expect(types).toContain("product_edited"); // Files rated adult can't be lowered by anyone: the creator is told at once. const lace = await upload(maker, c, "Lace", 3, "adult"); expect((await call(maker, "PATCH", `/api/v1/products/${lace}`, { rating: "general" })).json.error).toBe("files_adult"); }); test("a catalog's deletion goes to moderators; resolving it deletes the catalog", async () => { const owner = await person("leaver"); const operator = await person("leaverop"); const c = await catalogOf(owner, admin, "Leaving Shelf"); expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/team/leaverop?role=catalog-operator`, { role: "catalog-operator" })).status).toBe(200); expect((await call(operator, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Done" })).json.missing.permission).toBe("catalog:delete-request"); const first = (await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Closing shop" })).json.case; expect((await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Again" })).json).toMatchObject({ error: "requested", case: first }); // Its team sees the case; a decline leaves the catalog as it was. expect((await call(operator, "GET", `/api/v1/cases/${first}`)).json.case.kind).toBe("catalog-deletion"); expect((await call(mod, "POST", `/api/v1/cases/${first}/decide`, { decision: "decline", resolution: "Move your products first" })).status).toBe(200); expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}`)).json.catalog.status).toBe("approved"); const second = (await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Moved them" })).json.case; expect((await call(mod, "POST", `/api/v1/cases/${second}/decide`, { decision: "resolve", resolution: "" })).status).toBe(200); expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}`)).json.catalog.status).toBe("deleted"); expect((await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth)).status).toBe(401); }); test("removal requests: a catalog's team asks; moderators remove the product everywhere", async () => { const owner = await person("cautious"); const maker = await person("copier"); const c = await catalogOf(owner, admin, "Careful Shelf"); const shoes = await upload(maker, c, "Copied Shoes", 4); expect((await call(maker, "POST", "/api/v1/cases", { kind: "removal-request", about: { product: shoes }, catalog: c.id, reason: "x" })).status).toBe(403); const asked = await call(owner, "POST", "/api/v1/cases", { kind: "removal-request", about: { product: shoes }, catalog: c.id, reason: "Stolen work" }); expect(asked.status).toBe(201); expect((await call(owner, "POST", `/api/v1/cases/${asked.json.case.id}/decide`, { decision: "resolve", resolution: "" })).status).toBe(403); expect((await call(mod, "POST", `/api/v1/cases/${asked.json.case.id}/decide`, { decision: "resolve", resolution: "Confirmed" })).status).toBe(200); expect((await call(undefined, "GET", `/api/v1/products/${shoes}`)).status).toBe(410); expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_removed", data: { product: shoes, reason: "Confirmed" } }); }); test("appeals: the person affected appeals an action once; another moderator decides", async () => { const owner = await person("appealshelf"); const maker = await person("appealer"); const c = await catalogOf(owner, admin, "Appeal Shelf"); const coat = await upload(maker, c, "Fine Coat", 5); expect((await call(mod, "POST", `/api/v1/products/${coat}/remove`, { reason: "Looks stolen" })).status).toBe(200); expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_removed", data: { name: "Fine Coat", reason: "Looks stolen" } }); // The creator's audit log offers the removal for appeal; others can't appeal it. const entry = (await call(maker, "GET", `/api/v1/console/audit?product=${coat}`)).json.entries.find((e: any) => e.action === "product.remove"); expect(entry.appealable).toBe(true); expect((await call(owner, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "x" })).status).toBe(404); const appeal = await call(maker, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "I made it from scratch" }); expect(appeal.json.case).toMatchObject({ kind: "appeal", data: { action: "product.remove" } }); expect((await call(maker, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "Again" })).json.error).toBe("appealed"); // The moderator who removed it doesn't decide the appeal; an admin may (flagged in the audit log when it was theirs). const id = appeal.json.case.id; expect((await call(mod, "GET", `/api/v1/cases/${id}`)).json.canDecide).toBe(false); expect((await call(mod, "POST", `/api/v1/cases/${id}/decide`, { decision: "decline", resolution: "" })).json.message).toContain("Another moderator"); expect((await call(admin, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "Restoring it" })).json.case.status).toBe("resolved"); // Upholding it records the decision; the moderator restores the product (only moderators can). expect((await call(maker, "POST", `/api/v1/products/${coat}/unremove`, {})).status).toBe(403); expect((await call(admin, "POST", `/api/v1/products/${coat}/unremove`, { reason: "Appeal upheld" })).status).toBe(200); expect((await call(undefined, "GET", `/api/v1/products/${coat}`)).status).toBe(200); expect((await call(admin, "POST", `/api/v1/products/${coat}/unremove`, {})).status).toBe(404); expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_restored", data: { reason: "Appeal upheld" } }); }); test("membership decisions and moderators' edits reach people as notifications", async () => { const owner = await person("gatekeeper"); const joiner = await person("joiner"); const c = await catalogOf(owner, admin, "Gated Shelf", "approval"); expect((await call(joiner, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("requested"); expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/joiner`, { status: "member" })).status).toBe(200); expect((await notes(joiner)).notifications[0]).toMatchObject({ type: "membership_decided", data: { catalog: c.id, status: "member" } }); const maker = await person("edited"); const p = await upload(maker, c, "Wrong Name", 6); expect((await call(mod, "PATCH", `/api/v1/products/${p}`, { name: "Right Name", reason: "Typo" })).status).toBe(200); expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_edited", data: { product: p, name: "Right Name", fields: ["name"], reason: "Typo", by: "warden" }, }); // Their own edits don't notify them. const before = (await notes(maker)).notifications.length; await call(maker, "PATCH", `/api/v1/products/${p}`, { tags: ["mine"] }); expect((await notes(maker)).notifications.length).toBe(before); });