New uploads are drafts: only their creator and moderators see them, nobody can get or wear them, and they're deleted if not published within 30 days (their catalogs get delete changes). The creator reviews the info and publishes it. Editing a product's info is a permission check now (product:edit): moderators can edit anyone's with a reason the creator sees, lock fields (product:lock; locked fields need product:edit-locked) and lower ratings (product:rate-lower, not below its files'). Every change is kept in the product's info history, with who and why, and can be undone by the same rules. Products can be unlisted (out of search and lists, the link still works) and tagged; lists filter by tag and catalog. The console has a Products section (everyone's for moderators, by status) and a drafts to-do. A product's own category is left for later: the hub has no list of IMVU's categories to pick from. Co-Authored-By: Claude Opus 5.5 <[email protected]>
340 lines
18 KiB
TypeScript
340 lines
18 KiB
TypeScript
// 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<number, ImvuUser>();
|
|
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<string>(["https://moved.example.test"]);
|
|
|
|
let db: Database;
|
|
let close: () => Promise<void>;
|
|
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<string, string> = {}) {
|
|
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<Person> {
|
|
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: "[email protected]",
|
|
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<string, string> }, 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: "[email protected]" })).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"]);
|
|
});
|