// 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(); // IMVU products by ID, as IMVU's API answers them ("down": IMVU can't be reached for it). const imvuProducts = new Map | "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; let base: string; let stop: () => void; let hub: ReturnType; 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 = {}) { 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 { 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 }; 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: "takedowns@example.test", 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: `

Warm coatxsite

`, }); expect(edited.status).toBe(200); expect(edited.json.product.description).toBe( `

Warm coatxsite

`, ); 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://user@x.imvu.com/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]); });