// Managing catalogs (docs/products.md) with permissions (docs/console.md): settings, keys, teams, membership and // what it gates, invites, delisting, suspension, the changes feed, and the console's scoped audit log. 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; test("the first account is the admin", async () => { admin = await person("admin1"); expect((await call(admin, "GET", "/api/v1/me")).json.person.role).toBe("admin"); }); test("a catalog's owner changes its settings and key; others can't, and are told which permission they lack", async () => { const owner = await person("owner1"); const stranger = await person("stranger1"); const c = await catalogOf(owner, admin, "Owner Shelf"); // Anyone can see an approved catalog; the owner sees their permissions there. const seen = await call(owner, "GET", `/api/v1/catalogs/${c.id}`); expect(seen.status).toBe(200); expect(seen.json.roles).toContain("catalog-owner"); expect(seen.json.permissions).toContain("catalog:edit"); expect((await call(stranger, "GET", `/api/v1/catalogs/${c.id}`)).json.permissions).toEqual([]); // A stranger is refused, naming what's missing. const refused = await call(stranger, "PATCH", `/api/v1/catalogs/${c.id}`, { name: "Mine now" }); expect(refused.status).toBe(403); expect(refused.json).toMatchObject({ error: "forbidden", missing: { permission: "catalog:edit", scope: { catalog: c.id } } }); // The owner changes settings; an address must answer /health. const edited = await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { name: "Owner's Shelf", maxRating: "general", uploads: "members" }); expect(edited.status).toBe(200); expect(edited.json.catalog).toMatchObject({ name: "Owner's Shelf", maxRating: "general", uploads: "members" }); expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { url: "https://nowhere.example.test" })).json.error).toBe("unreachable"); expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { url: "https://moved.example.test/" })).json.catalog.url).toBe( "https://moved.example.test", ); // A new key: the old one stops working at once. const rotated = await call(owner, "POST", `/api/v1/catalogs/${c.id}/key`); expect(rotated.status).toBe(200); expect((await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth)).status).toBe(401); expect( (await call(undefined, "GET", "/api/v1/catalog/changes", undefined, { Authorization: `Catalog ${c.id}:${rotated.json.key}` })).status, ).toBe(200); }); test("teams: the owner gives the operator role; operators manage members but not the team", async () => { const owner = await person("owner2"); const op = await person("op2"); const other = await person("other2"); const c = await catalogOf(owner, admin, "Team Shelf", "approval"); expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/team/other2`, { role: "catalog-operator" })).status).toBe(403); const added = await call(owner, "PUT", `/api/v1/catalogs/${c.id}/team/op2`, { role: "catalog-operator" }); expect(added.status).toBe(200); expect(added.json.team.roles).toMatchObject([{ username: "op2", role: "catalog-operator" }]); // The operator holds member:approve, not team:manage. const perms = (await call(op, "GET", `/api/v1/console/permissions?catalog=${c.id}`)).json; expect(perms.roles).toEqual(["catalog-operator"]); expect(perms.permissions).toContain("member:approve"); expect(perms.permissions).not.toContain("team:manage"); expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/team/other2`, { role: "catalog-operator" })).json.missing.permission).toBe( "team:manage", ); // An approval catalog: other asks, the operator approves. expect((await call(other, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("requested"); const requests = await call(op, "GET", `/api/v1/catalogs/${c.id}/members?status=requested`); expect(requests.json.members).toMatchObject([{ username: "other2", status: "requested" }]); expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/members/other2`, { status: "member" })).status).toBe(200); expect((await call(other, "GET", `/api/v1/catalogs/${c.id}`)).json.membership).toBe("member"); // Operators leave on their own; the console lists the catalog for them while they're on the team. expect((await call(op, "GET", "/api/v1/console/catalogs")).json.catalogs.map((x: any) => x.id)).toEqual([c.id]); expect((await call(op, "DELETE", `/api/v1/catalogs/${c.id}/team/op2?role=catalog-operator`)).status).toBe(200); expect((await call(op, "GET", "/api/v1/console/catalogs")).status).toBe(403); }); test("membership gates getting and wearing products; seeing them doesn't need it", async () => { const owner = await person("owner3"); const maker = await person("maker3"); const fan = await person("fan3"); const c = await catalogOf(owner, admin, "Invite Shelf", "invite"); // Uploads to an invite catalog: anyone may upload by default. const pid = await upload(maker, c, "Hat", 31); // Seeing works; getting doesn't, and says which catalogs host it. const page = await call(fan, "GET", `/api/v1/products/${pid}`); expect(page.json).toMatchObject({ available: false, catalogs: [{ id: c.id, membership: "invite", joined: null }] }); expect((await call(fan, "GET", `/api/v1/products/${pid}/files`)).status).toBe(200); const refused = await call(fan, "PUT", `/api/v1/library/${pid}`); expect(refused.status).toBe(403); expect(refused.json).toMatchObject({ error: "members_only", catalogs: [{ id: c.id }] }); // Invite only: joining without an invite is refused; with one, they're in. expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.error).toBe("invite_only"); const inv = await call(owner, "POST", `/api/v1/catalogs/${c.id}/invites`, { maxUses: 1 }); expect(inv.status).toBe(201); expect(inv.json.invite.url).toBe(`${webURL}/catalogs/${c.id}?invite=${inv.json.invite.code}`); expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, { invite: inv.json.invite.code })).json.membership).toBe("member"); const late = await person("late3"); expect((await call(late, "POST", `/api/v1/catalogs/${c.id}/join`, { invite: inv.json.invite.code })).json.error).toBe("invite_invalid"); expect((await call(fan, "PUT", `/api/v1/library/${pid}`)).status).toBe(200); expect((await call(fan, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([pid]); // Banned: the product stays in the table but isn't listed (or wearable) until they're back. expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/fan3`, { status: "banned" })).status).toBe(200); expect((await call(fan, "GET", "/api/v1/library")).json.library).toEqual([]); expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.error).toBe("banned"); expect((await call(owner, "DELETE", `/api/v1/catalogs/${c.id}/members/fan3`)).status).toBe(204); expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/fan3`, { status: "member" })).status).toBe(200); expect((await call(fan, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([pid]); // Its creator always has their own product. expect((await call(maker, "PUT", `/api/v1/library/${pid}`)).status).toBe(200); }); test("a catalog's upload policy and rating ceiling apply to uploads", async () => { const owner = await person("owner4"); const maker = await person("maker4"); const c = await catalogOf(owner, admin, "Strict Shelf"); expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { uploads: "members", maxRating: "general" })).status).toBe(200); const refused = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id }); expect(refused).toMatchObject({ status: 403, json: { error: "forbidden" } }); expect((await call(maker, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("member"); const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id }); expect(tok.status).toBe(200); const adult = await call( undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: tok.json.token, files: [file("index.xml", 41)], rating: "adult", parent: 0, name: "Spicy", notes: "" }, c.auth, ); expect(adult).toMatchObject({ status: 400, json: { error: "rating_ceiling" } }); // Team only: members can't either. expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { uploads: "team" })).status).toBe(200); expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(403); expect((await call(owner, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(200); }); test("delisting and suspension reach the catalog through its changes feed", async () => { const owner = await person("owner5"); const maker = await person("maker5"); const c = await catalogOf(owner, admin, "Feed Shelf"); const pid = await upload(maker, c, "Scarf", 51); expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}/products`)).json.products).toMatchObject([{ id: pid, revisions: [1] }]); expect((await call(maker, "DELETE", `/api/v1/catalogs/${c.id}/products/${pid}?reason=no`)).status).toBe(403); const delisted = await call(owner, "DELETE", `/api/v1/catalogs/${c.id}/products/${pid}?reason=${encodeURIComponent("off-topic")}`); expect(delisted.json.revisions).toEqual([1]); expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}/products`)).json.products).toEqual([]); const feed = await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth); expect(feed.json.changes).toEqual([{ type: "delete", product: pid, revision: 1, files: [file("index.xml", 51)] }]); const cursor = feed.json.cursor; expect((await call(undefined, "GET", `/api/v1/catalog/changes?after=${cursor}`, undefined, c.auth)).json.changes).toEqual([]); // Owners can't suspend their own catalog; moderators can. A suspended catalog can't register revisions, still // reads its feed, and is told. expect((await call(owner, "POST", `/api/v1/catalogs/${c.id}/suspend`, { reason: "x" })).status).toBe(403); expect((await call(admin, "POST", `/api/v1/catalogs/${c.id}/suspend`, { reason: "spam" })).json.catalog).toMatchObject({ status: "suspended", statusReason: "spam", }); expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(404); const told = await call(undefined, "GET", `/api/v1/catalog/changes?after=${cursor}`, undefined, c.auth); expect(told.json.changes).toEqual([{ type: "status", status: "suspended" }]); expect((await call(admin, "POST", `/api/v1/catalogs/${c.id}/reinstate`)).json.catalog.status).toBe("approved"); }); test("the console is for everyone who controls something; its audit log shows only what they may see", async () => { const owner = await person("owner6"); const maker = await person("maker6"); const nobody = await person("nobody6"); const c = await catalogOf(owner, admin, "Audit Shelf"); const other = await catalogOf(await person("owner6b"), admin, "Other Shelf"); const pid = await upload(maker, c, "Gloves", 61); expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { contact: "legal@example.test" })).status).toBe(200); expect((await call(maker, "PATCH", `/api/v1/products/${pid}`, { name: "Warm Gloves" })).status).toBe(200); expect((await call(admin, "PATCH", `/api/v1/catalogs/${other.id}`, { name: "Other Shelf 2" })).status).toBe(200); // Sections by what they control. expect((await call(nobody, "GET", "/api/v1/console")).status).toBe(403); expect((await call(nobody, "GET", "/api/v1/console/audit")).status).toBe(403); const ownerConsole = (await call(owner, "GET", "/api/v1/console")).json; expect(ownerConsole.sections).toEqual(["overview", "catalogs", "audit"]); expect(ownerConsole.moderation).toBeNull(); expect((await call(maker, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "audit"]); expect((await call(admin, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "catalogs", "people", "audit", "settings"]); // The owner sees their catalog's entries, not the other catalog's; the maker sees their product's. const ownerLog = (await call(owner, "GET", "/api/v1/console/audit")).json.entries; expect(ownerLog.some((e: any) => e.action === "catalog.edit" && e.tags.catalog === c.id)).toBe(true); expect(ownerLog.some((e: any) => e.tags.catalog === other.id)).toBe(false); expect(ownerLog.find((e: any) => e.tags.catalog === c.id).tagNames[c.id]).toBe("Audit Shelf"); const makerLog = (await call(maker, "GET", "/api/v1/console/audit")).json.entries; expect(makerLog.map((e: any) => e.action)).toContain("product.edit"); expect(makerLog.some((e: any) => e.tags.catalog === c.id && e.action === "catalog.edit")).toBe(false); // Admins see everything, and filter. const filtered = (await call(admin, "GET", `/api/v1/console/audit?catalog=${other.id}&action=catalog.edit`)).json.entries; expect(filtered).toHaveLength(1); expect(filtered[0]).toMatchObject({ actor: "admin1", action: "catalog.edit" }); expect((await call(admin, "GET", "/api/v1/console/audit?actor=maker6")).json.entries.every((e: any) => e.actor === "maker6")).toBe(true); expect((await call(admin, "GET", "/api/v1/console/audit?q=legal@example")).json.entries).toHaveLength(1); }); test("outfits relay only the hub products the wearer may wear; local packs pass", async () => { const { Realtime } = await import("../src/realtime"); const rt = new Realtime({ owner: async () => "u1", wearable: async (_, ids) => new Set(ids.filter((id) => id === "aaaaaaaaaa")), }); rt.open("s1", "u1", () => {}); await rt.message("s1", { type: "session", imvu_id: 7 }); await rt.message("s1", { type: "outfit", items: [ { id: "aaaaaaaaaa", name: "Mine", adult: false }, { id: "bbbbbbbbbb", name: "Members only", adult: false }, { id: "local-pack-1", name: "A pack", adult: false }, ], }); expect(rt.online()[0]!.outfit.map((i) => i.id)).toEqual(["aaaaaaaaaa", "local-pack-1"]); });