Files
t5de-hub/apps/api/test/products.test.ts
T
dhkatzandClaude Opus 5.5 97ce2be569
CI / test (push) Successful in 1m4s
Deleting products: by their creators, and removal by moderation
A creator deletes a product: it's no longer listed and nobody new can get it, while people who have it
keep it (its page, files and library entries stay, and rooms keep showing it), as old revisions stay
available. Moderation removes one everywhere, with a reason: its page and files answer 410, and it
leaves libraries and rooms. Both are on the product's page and in the audit log.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:10:24 -07:00

316 lines
19 KiB
TypeScript

// The product registry: catalogs, uploads registered by catalogs, product info, revisions, libraries, tokens.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { createLocalJWKSet, jwtVerify } from "jose";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, imvuImage, parse, parseProduct, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const apiURL = "http://api.test";
const accounts = new Map<number, ImvuUser>();
// IMVU products by ID, as IMVU's API answers them ("down": IMVU can't be reached for it).
const imvuProducts = new Map<number, Record<string, unknown> | "down">();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const pm = url.pathname.match(/^\/product\/product-(\d+)$/);
if (pm) {
const pid = Number(pm[1]);
const data = imvuProducts.get(pid);
if (data === "down") throw new Error("connection refused");
if (!data) return Response.json({ status: "failure", error: "NODE-001" }, { status: 404 });
return Response.json({ status: "success", denormalized: { [`https://api.imvu.com/product/product-${pid}`]: { data } } });
}
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;
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
let hub: ReturnType<typeof createApp>;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU), apiURL });
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 };
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, imvu?: { id: number; ageVerified: boolean }): 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 }),
});
const p = { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") };
if (imvu) {
accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: imvu.ageVerified, accessPass: false, picture: null });
const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` });
accounts.get(imvu.id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200);
}
return p;
}
// Set by the first test for the next: the admin and an approved catalog.
let shared: { admin: Person; catalogID: string; auth: Record<string, string> };
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
test("catalogs register uploads; products have revisions, info and libraries", async () => {
const admin = await person("admin1");
const maker = await person("maker", { id: 501, ageVerified: true });
const kid = await person("kid", { id: 502, ageVerified: false });
// A catalog registers; it can't do anything until approved.
const reg = await call(maker, "POST", "/api/v1/catalogs", {
name: "Maker's Shelf",
url: "http://localhost:4102/",
contact: "[email protected]",
visibility: "public",
membership: "open",
});
expect(reg.status).toBe(201);
const catalogID = reg.json.catalog.id as string;
const auth = { Authorization: `Catalog ${catalogID}:${reg.json.key}` };
shared = { admin, catalogID, auth };
expect(reg.json.catalog.url).toBe("http://localhost:4102");
expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs).toHaveLength(0);
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).status).toBe(404);
expect((await call(admin, "POST", `/api/v1/console/catalogs/${catalogID}/approve`)).status).toBe(200);
expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs.map((c: any) => c.name)).toEqual(["Maker's Shelf"]);
// The maker gets an upload token; the catalog registers what was uploaded with it.
const up = await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID });
expect(up.json.url).toBe("http://localhost:4102/api/v1/uploads");
const files = [file("tex.png", 2), file("index.xml", 1)];
const revision = (token: string, extra: object = {}) =>
call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files, rating: "general", parent: 500, name: "Blue Coat", notes: "", ...extra }, auth);
expect((await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: up.json.token, files, rating: "general", parent: 0, name: "x", notes: "" }, { Authorization: `Catalog ${catalogID}:wrong` })).status).toBe(401);
const made = await revision(up.json.token);
expect(made.status).toBe(201);
expect(made.json.revision).toBe(1);
const id = made.json.product as string;
expect(id).toMatch(/^[a-z2-7]{10}$/);
// Its page: info, revisions (files sorted), hosting.
const page = (await call(undefined, "GET", `/api/v1/products/${id}`)).json;
expect(page.product).toMatchObject({ name: "Blue Coat", rating: "general", latest: 1, creator: "maker" });
expect(page.revisions[0].files.map((f: any) => f.name)).toEqual(["index.xml", "tex.png"]);
expect(page.revisions[0].catalogs).toEqual(["Maker's Shelf"]);
// Descriptions are cleaned by the allowlist.
const edited = await call(maker, "PATCH", `/api/v1/products/${id}`, {
description: `<p style="color:red;position:fixed">Warm <b>coat</b><script>alert(1)</script><a href="javascript:x">x</a><a href="https://example.test" onclick="x()">site</a><img src="http://x/a.png"><img src="relative.png"><img src="https://img.test/a.png"></p>`,
});
expect(edited.status).toBe(200);
expect(edited.json.product.description).toBe(
`<p style="color:red">Warm <b>coat</b><a rel="nofollow ugc noopener noreferrer" target="_blank">x</a><a href="https://example.test" rel="nofollow ugc noopener noreferrer" target="_blank">site</a><img src="https://img.test/a.png" /></p>`,
);
expect((await call(kid, "PATCH", `/api/v1/products/${id}`, { name: "Mine now" })).status).toBe(403);
// A new revision needs notes and different files.
const up2 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID, product: id })).json.token;
expect((await revision(up2)).status).toBe(400);
expect((await revision(up2, { notes: "same" })).status).toBe(409);
const r2 = await revision(up2, { notes: "Brighter", files: [file("index.xml", 1), file("tex.png", 3)] });
expect(r2.json).toEqual({ product: id, revision: 2 });
// Someone else's upload token can't add to it.
expect((await call(kid, "POST", "/api/v1/products/upload", { catalog: catalogID, product: id })).status).toBe(403);
// The library: get it, the proxy resolves it to the latest, or the revision the person stays on.
expect((await call(kid, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
expect((await call(kid, "GET", "/api/v1/library")).json.library.map((l: any) => [l.product.id, l.product.creator])).toEqual([[id, "maker"]]);
let files2 = (await call(kid, "GET", `/api/v1/products/${id}/files`)).json;
expect(files2).toMatchObject({ product: id, revision: 2, parent: 500, name: "Blue Coat", creator: "maker", catalogs: ["http://localhost:4102"] });
expect((await call(kid, "PATCH", `/api/v1/library/${id}`, { stayOn: 1 })).status).toBe(200);
expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).json.revision).toBe(1);
// Retracting revision 1 moves those staying on it to the nearest one that isn't.
await call(maker, "POST", `/api/v1/products/${id}/revisions/1/retract`);
expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).json.revision).toBe(2);
// The revision token is the hub's signature on the files, checkable with its public keys.
const keys = createLocalJWKSet((await call(undefined, "GET", "/.well-known/t5de-keys.json")).json);
const { payload } = await jwtVerify(files2.token, keys, { issuer: apiURL });
expect(payload).toMatchObject({ typ: "t5de-revision", product: id, revision: 2, rating: "general", parent: 500 });
// Access tokens say whether the person is adult.
const access = async (p: Person) => (await jwtVerify((await call(p, "POST", "/api/v1/access")).json.token, keys)).payload;
expect(await access(maker)).toMatchObject({ typ: "t5de-access", adult: true });
expect(await access(kid)).toMatchObject({ typ: "t5de-access", adult: false });
// Adult products are only for adults.
const up3 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const adultID = (await revision(up3, { rating: "adult", name: "Lace" })).json.product;
expect((await call(maker, "GET", `/api/v1/products/${adultID}`)).status).toBe(200);
expect((await call(kid, "GET", `/api/v1/products/${adultID}`)).status).toBe(404);
expect((await call(kid, "PUT", `/api/v1/library/${adultID}`)).status).toBe(404);
expect((await call(undefined, "GET", "/api/v1/products")).json.products.map((p: any) => p.name)).toEqual(["Blue Coat"]);
expect((await call(maker, "GET", "/api/v1/products?q=lac")).json.products.map((p: any) => p.name)).toEqual(["Lace"]);
// Lowering a rating waits for review.
expect((await call(maker, "PATCH", `/api/v1/products/${adultID}`, { rating: "general" })).json.error).toBe("review_needed");
expect((await call(kid, "DELETE", `/api/v1/library/${id}`)).status).toBe(204);
expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]);
});
test("IMVU product info: parsing and image addresses", () => {
expect(imvuImage("https://userimages-akm.imvu.com/productdata/a.png")).toBe("https://userimages-akm.imvu.com/productdata/a.png");
expect(imvuImage("//userimages-akm.imvu.com/a.png")).toBe("https://userimages-akm.imvu.com/a.png");
for (const bad of ["http://userimages-akm.imvu.com/a.png", "https://imvu.com.evil.test/a.png", "https://evilimvu.com/a.png", "https://[email protected]/a.png", "javascript:alert(1)", 5, null])
expect(imvuImage(bad)).toBeNull();
const answer = {
denormalized: {
"https://api.imvu.com/product/product-9": {
data: { product_image: "https://x.imvu.com/i.png", gender: "female", category_path: [{ id: 106, name: "Clothing" }, { id: "x", name: "Bad" }, { id: 716, name: " Coats & Jackets " }] },
},
},
};
expect(parseProduct(answer, 9)).toEqual({
image: "https://x.imvu.com/i.png",
categories: [{ id: 106, name: "Clothing" }, { id: 716, name: "Coats & Jackets" }],
gender: "female",
});
expect(parseProduct(answer, 10)).toBeNull();
});
test("derived products carry their IMVU parent's image and categories", async () => {
const { catalogID, auth } = shared;
const dresser = await person("dresser", { id: 601, ageVerified: false });
const coat = {
product_id: 7001,
product_name: "Coat",
rating: "GA",
product_image: "https://userimages-akm.imvu.com/productdata/coat.png",
gender: "female",
category_path: [
{ id: 106, name: "Clothing" },
{ id: 40, name: "Clothing for Female" },
{ id: 128, name: "Tops" },
{ id: 716, name: "Coats & Jackets" },
],
};
imvuProducts.set(7001, coat);
imvuProducts.set(7002, { ...coat, product_image: "https://elsewhere.test/hat.png", category_path: [{ id: 75, name: "Hair" }] });
imvuProducts.set(7003, "down");
const upload = async (name: string, parent: number) => {
const token = (await call(dresser, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("index.xml", parent || 9)], rating: "general", parent, name, notes: "" }, auth);
expect(r.status).toBe(201);
return r.json.product as string;
};
const coatID = await upload("Dresser Coat", 7001);
const hatID = await upload("Dresser Hat", 7002);
const downID = await upload("Dresser Down", 7003);
const plainID = await upload("Dresser Plain", 0);
const missingID = await upload("Dresser Missing", 7999);
const page = async (id: string) => (await call(undefined, "GET", `/api/v1/products/${id}`)).json.product;
// Looked up in the background after registering.
for (let i = 0; i < 100 && (await page(coatID)).image === null; i++) await Bun.sleep(20);
expect(await page(coatID)).toMatchObject({ parent: 7001, image: coat.product_image, categories: coat.category_path });
for (let i = 0; i < 100 && (await page(hatID)).categories.length === 0; i++) await Bun.sleep(20);
expect(await page(hatID)).toMatchObject({ parent: 7002, image: null, categories: [{ id: 75, name: "Hair" }] });
expect(await page(plainID)).toMatchObject({ parent: 0, image: null, categories: [] });
expect(await page(downID)).toMatchObject({ parent: 7003, image: null, categories: [] });
// Lists carry the same fields; category filters by any ID in the path.
const list = async (qs: string) => (await call(undefined, "GET", `/api/v1/products?creator=dresser${qs}`)).json.products;
const names = async (qs: string) => (await list(qs)).map((p: any) => p.name).sort();
expect((await list("")).find((p: any) => p.id === coatID)).toMatchObject({ parent: 7001, image: coat.product_image, categories: coat.category_path });
expect(await names("&category=128")).toEqual(["Dresser Coat"]);
expect(await names("&category=106")).toEqual(["Dresser Coat"]);
expect(await names("&category=75")).toEqual(["Dresser Hat"]);
expect(await names("&category=1")).toEqual([]);
expect(await names("&limit=2")).toHaveLength(2);
expect((await call(undefined, "GET", "/api/v1/products?limit=201")).status).toBe(400);
expect((await call(undefined, "GET", "/api/v1/products?category=x")).status).toBe(400);
// IMVU was down for one: the periodic pass fills it in once IMVU answers; one IMVU doesn't have stays empty.
imvuProducts.set(7003, { ...coat, category_path: [{ id: 3114, name: "Body" }] });
await hub.products.fillParentInfo(50);
expect(await page(downID)).toMatchObject({ image: coat.product_image, categories: [{ id: 3114, name: "Body" }] });
expect(await page(missingID)).toMatchObject({ parent: 7999, image: null, categories: [] });
expect(await hub.products.fillParentInfo(50)).toBe(0);
// The library shows them too.
expect((await call(dresser, "PUT", `/api/v1/library/${coatID}`)).status).toBe(200);
expect((await call(dresser, "GET", "/api/v1/library")).json.library[0].product).toMatchObject({ image: coat.product_image });
});
test("an IMVU account's avatar picture is kept only from IMVU's own hosts", () => {
const user = (thumbnail_url: unknown) =>
parse({ denormalized: { "https://api.imvu.com/user/user-5": { data: { legacy_cid: 5, username: "Av", thumbnail_url } } } });
expect(user("https://webasset-akm.imvu.com/resized_image/p.png").picture).toBe("https://webasset-akm.imvu.com/resized_image/p.png");
expect(user("https://evil.example/p.png").picture).toBeNull();
expect(user(undefined).picture).toBeNull();
});
test("creators delete products (those who have them keep them); moderation removes them everywhere", async () => {
const { admin, catalogID, auth } = shared;
const maker = await person("deleter", { id: 511, ageVerified: true });
const owner = await person("keeper", { id: 512, ageVerified: true });
const late = await person("latecomer", { id: 513, ageVerified: true });
const make = async (name: string) => {
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file(`${name}.xml`, 1)], rating: "general", parent: 500, name, notes: "" }, auth);
return r.json.product as string;
};
const listed = async () => (await call(undefined, "GET", "/api/v1/products?q=gone")).json.products.map((p: any) => p.name);
const id = await make("Gone Hat");
expect((await call(owner, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
// Only its creator deletes it; then it's not listed and nobody new adds it, but its page, the library entry and
// the files stay for those who have it.
expect((await call(owner, "DELETE", `/api/v1/products/${id}`)).status).toBe(403);
expect((await call(maker, "DELETE", `/api/v1/products/${id}`)).status).toBe(204);
expect(await listed()).toEqual([]);
expect((await call(undefined, "GET", `/api/v1/products/${id}`)).json.product.deleted).toBe(true);
expect((await call(late, "PUT", `/api/v1/library/${id}`)).json.error).toBe("deleted");
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
expect((await call(owner, "GET", `/api/v1/products/${id}/files`)).status).toBe(200);
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { name: "Back" })).json.error).toBe("deleted");
// Moderation removes one: no page, no files, out of libraries.
const bad = await make("Gone Shoes");
expect((await call(owner, "PUT", `/api/v1/library/${bad}`)).status).toBe(200);
expect((await call(maker, "POST", `/api/v1/products/${bad}/remove`, { reason: "Stolen" })).status).toBe(403);
expect((await call(admin, "POST", `/api/v1/products/${bad}/remove`, { reason: "Stolen" })).status).toBe(200);
expect((await call(undefined, "GET", `/api/v1/products/${bad}`)).status).toBe(410);
expect((await call(owner, "GET", `/api/v1/products/${bad}/files`)).status).toBe(410);
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
});