Cases are one queue for what someone has to decide, each with a thread (internal notes for those deciding), an assignee and a decision. Reports about a product go to the teams of the catalogs hosting it and to moderators, and only the reporter and moderators know who reported. A creator lowering a rating opens a rating review; a catalog's owner asking to delete it opens a deletion request; a catalog's team can ask for a product's removal everywhere; the person an action affected can appeal it once, decided by another moderator (an admin may decide their own, flagged). Resolving carries the case out: the rating is lowered, the product removed, the catalog deleted. Notifications reach people on the website (a bell with the unread count) and their proxies (realtime): moderators' edits to their products, removals and restores, decisions and replies on their cases, membership decisions. Also: moderators can restore a removed product, and a product's creator and moderators can see it when it's adult without an adult account (getting and wearing it still need one). Co-Authored-By: Claude Opus 5.5 <[email protected]>
463 lines
30 KiB
TypeScript
463 lines
30 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).toMatchObject({ revision: 1, draft: true });
|
|
const id = made.json.product as string;
|
|
expect(id).toMatch(/^[a-z2-7]{10}$/);
|
|
|
|
// A new product is a draft: only its creator (and moderators) see it, and nobody can get it until it's published.
|
|
expect((await call(undefined, "GET", `/api/v1/products/${id}`)).status).toBe(404);
|
|
expect((await call(kid, "GET", `/api/v1/products/${id}`)).status).toBe(404);
|
|
expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).status).toBe(404);
|
|
expect((await call(admin, "GET", `/api/v1/products/${id}`)).json.product.status).toBe("draft");
|
|
expect((await call(maker, "GET", `/api/v1/products/${id}`)).json.product).toMatchObject({ status: "draft", publishedAt: null });
|
|
expect((await call(maker, "PUT", `/api/v1/library/${id}`)).json.error).toBe("draft");
|
|
expect((await call(maker, "GET", "/api/v1/products?creator=maker")).json.products).toEqual([]);
|
|
expect((await call(kid, "POST", `/api/v1/products/${id}/publish`)).status).toBe(403);
|
|
expect((await call(maker, "POST", `/api/v1/products/${id}/publish`)).json.product.status).toBe("published");
|
|
expect((await call(maker, "POST", `/api/v1/products/${id}/publish`)).json.error).toBe("published");
|
|
|
|
// 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, draft: false });
|
|
// 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;
|
|
await call(maker, "POST", `/api/v1/products/${adultID}/publish`);
|
|
expect((await call(maker, "GET", `/api/v1/products/${adultID}`)).status).toBe(200);
|
|
expect((await call(kid, "GET", `/api/v1/products/${adultID}`)).status).toBe(404);
|
|
// Its creator and moderators see it without an adult account; getting or wearing it still needs one.
|
|
expect((await call(admin, "GET", `/api/v1/products/${adultID}`)).status).toBe(200);
|
|
expect((await call(admin, "PUT", `/api/v1/library/${adultID}`)).status).toBe(404);
|
|
expect((await call(admin, "GET", `/api/v1/products/${adultID}/files`)).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"]);
|
|
|
|
// Its files are rated adult, so it can't be lowered (a revision rated general can be reviewed: cases.test.ts).
|
|
expect((await call(maker, "PATCH", `/api/v1/products/${adultID}`, { rating: "general" })).json.error).toBe("files_adult");
|
|
|
|
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);
|
|
await call(dresser, "POST", `/api/v1/products/${r.json.product}/publish`);
|
|
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);
|
|
|
|
// A product can be filed under another category the hub has seen, in its parent's top-level category: a jacket
|
|
// made from a plain top goes under Coats & Jackets, never under Hair or Body (another kind of product).
|
|
imvuProducts.set(7004, { ...coat, category_path: coat.category_path.slice(0, 3) });
|
|
const jacketID = await upload("Dresser Jacket", 7004);
|
|
for (let i = 0; i < 100 && (await page(jacketID)).categories.length === 0; i++) await Bun.sleep(20);
|
|
const { choices, parent } = (await call(dresser, "GET", `/api/v1/products/${jacketID}/categories`)).json;
|
|
expect(parent.map((c: any) => c.id)).toEqual([106, 40, 128]);
|
|
expect(choices.map((c: any) => c.at(-1).id).sort()).toEqual([106, 128, 40, 716]);
|
|
expect((await call(undefined, "GET", `/api/v1/products/${jacketID}/categories`)).status).toBe(401);
|
|
expect((await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: 75 })).json.error).toBe("invalid");
|
|
const filed = await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: 716 });
|
|
expect(filed.json.fields).toEqual(["category"]);
|
|
expect(filed.json.product.categories).toEqual(coat.category_path);
|
|
expect(filed.json.product.category).toEqual(coat.category_path);
|
|
expect(await names("&category=716")).toEqual(["Dresser Coat", "Dresser Jacket"]);
|
|
// Back to its parent's: the history keeps paths, and undoing puts the chosen one back.
|
|
expect((await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: null })).json.product.categories).toEqual(parent);
|
|
expect(await names("&category=716")).toEqual(["Dresser Coat"]);
|
|
const [cleared] = (await call(dresser, "GET", `/api/v1/products/${jacketID}/history`)).json.entries;
|
|
expect(cleared.changes.category).toEqual({ from: coat.category_path, to: null });
|
|
expect((await call(dresser, "POST", `/api/v1/products/${jacketID}/history/${cleared.id}/revert`, {})).json.product.categories).toEqual(coat.category_path);
|
|
|
|
// 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);
|
|
await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`);
|
|
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]);
|
|
});
|
|
|
|
test("product info: moderators edit with reasons and lock fields; history and reverts; unlisted and tags", async () => {
|
|
const { admin, catalogID, auth } = shared;
|
|
const maker = await person("tailor", { id: 521, ageVerified: true });
|
|
const other = await person("browser", { id: 522, ageVerified: true });
|
|
const mod = await person("warden", { id: 523, ageVerified: true });
|
|
const modID = (await call(mod, "GET", "/api/v1/me")).json.person.id;
|
|
expect((await call(admin, "POST", `/api/v1/console/people/${modID}/role`, { role: "moderator" })).status).toBe(200);
|
|
|
|
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
|
|
const id = (await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("vest.xml", 7)], rating: "general", parent: 0, name: "Vest", notes: "" }, auth)).json.product as string;
|
|
|
|
// The creator sets it up while it's a draft: tags (cleaned), unlisted; then publishes.
|
|
const set = await call(maker, "PATCH", `/api/v1/products/${id}`, { tags: [" Formal ", "formal", "Wool Blend"], listed: false });
|
|
expect(set.json.fields).toEqual(["tags", "listed"]);
|
|
expect(set.json.product).toMatchObject({ tags: ["formal", "wool blend"], listed: false });
|
|
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { tags: ["<b>"] })).json.error).toBe("invalid");
|
|
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { listed: false })).json.fields).toEqual([]);
|
|
await call(maker, "POST", `/api/v1/products/${id}/publish`);
|
|
|
|
// Unlisted: not in lists or search for others (its creator still sees it); its link works, and people can get it.
|
|
const names = async (p: Person | undefined, qs: string) => (await call(p, "GET", `/api/v1/products?${qs}`)).json.products.map((x: any) => x.name);
|
|
expect(await names(other, "creator=tailor")).toEqual([]);
|
|
expect(await names(maker, "creator=tailor")).toEqual(["Vest"]);
|
|
expect((await call(other, "GET", `/api/v1/products/${id}`)).status).toBe(200);
|
|
expect((await call(other, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
|
|
await call(maker, "PATCH", `/api/v1/products/${id}`, { listed: true });
|
|
expect(await names(other, "tag=formal")).toEqual(["Vest"]);
|
|
expect(await names(other, "tag=Wool%20Blend")).toEqual(["Vest"]);
|
|
expect(await names(other, "tag=denim")).toEqual([]);
|
|
expect(await names(other, `creator=tailor&catalog=${catalogID}`)).toEqual(["Vest"]);
|
|
|
|
// Others can't edit; a moderator can, with a reason the creator sees.
|
|
expect((await call(other, "PATCH", `/api/v1/products/${id}`, { name: "Mine" })).json).toMatchObject({
|
|
error: "forbidden",
|
|
missing: { permission: "product:edit" },
|
|
});
|
|
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { name: "Wool Vest" })).json.error).toBe("reason_needed");
|
|
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { name: "Wool Vest", reason: "Misleading name" })).status).toBe(200);
|
|
|
|
// Locks: the creator can't change a locked field; moderators can. The page lists what the person may do.
|
|
expect((await call(maker, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "x" })).status).toBe(403);
|
|
expect((await call(mod, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "" })).json.error).toBe("reason_needed");
|
|
expect((await call(mod, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "Keeps renaming it" })).json.product.locked).toEqual(["name"]);
|
|
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { name: "Vest!" })).json.error).toBe("locked");
|
|
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { description: "<p>Warm</p>" })).status).toBe(200);
|
|
const page = (await call(maker, "GET", `/api/v1/products/${id}`)).json;
|
|
expect(page.product.locked).toEqual(["name"]);
|
|
expect(page.permissions).toContain("product:edit");
|
|
expect(page.permissions).not.toContain("product:edit-locked");
|
|
expect((await call(mod, "GET", `/api/v1/products/${id}`)).json.permissions).toContain("product:edit-locked");
|
|
|
|
// Ratings: anyone with product:edit raises it; lowering is for moderators, and not below its files' rating.
|
|
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { rating: "adult" })).status).toBe(200);
|
|
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { rating: "general" })).json).toMatchObject({
|
|
product: { rating: "adult" },
|
|
review: expect.any(Number),
|
|
});
|
|
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { rating: "general", reason: "Not adult" })).status).toBe(200);
|
|
|
|
// The history: every change, newest first, with who and why. Only the creator and moderators read it.
|
|
expect((await call(other, "GET", `/api/v1/products/${id}/history`)).status).toBe(403);
|
|
const history = (await call(maker, "GET", `/api/v1/products/${id}/history`)).json.entries;
|
|
expect(history.map((e: any) => [e.kind, Object.keys(e.changes).join(), e.by, e.byCreator])).toEqual([
|
|
["edit", "rating", "warden", false],
|
|
["edit", "rating", "tailor", true],
|
|
["edit", "description", "tailor", true],
|
|
["lock", "locked", "warden", false],
|
|
["edit", "name", "warden", false],
|
|
["edit", "listed", "tailor", true],
|
|
["publish", "status", "tailor", true],
|
|
["edit", "tags,listed", "tailor", true],
|
|
]);
|
|
expect(history[4]).toMatchObject({ reason: "Misleading name", changes: { name: { from: "Vest", to: "Wool Vest" } } });
|
|
|
|
// Reverting puts back what an entry changed, by the same rules: the name is locked for the creator.
|
|
const rename = history[4].id;
|
|
expect((await call(maker, "POST", `/api/v1/products/${id}/history/${rename}/revert`, {})).json.error).toBe("locked");
|
|
const reverted = await call(mod, "POST", `/api/v1/products/${id}/history/${rename}/revert`, { reason: "My mistake" });
|
|
expect(reverted.json.product.name).toBe("Vest");
|
|
expect((await call(mod, "POST", `/api/v1/products/${id}/history/${rename}/revert`, { reason: "Again" })).json.error).toBe("unchanged");
|
|
const latest = (await call(maker, "GET", `/api/v1/products/${id}/history`)).json.entries[0];
|
|
expect(latest).toMatchObject({ kind: "revert", revertOf: rename, reason: "My mistake" });
|
|
|
|
// Moderators' edits are in the creator's audit log, tagged with the product.
|
|
const audit = (await call(maker, "GET", `/api/v1/console/audit?product=${id}`)).json.entries;
|
|
expect(audit.map((e: any) => e.action)).toContain("product.lock");
|
|
|
|
// The console's product list: the creator's own (drafts too); moderators everyone's, filtered.
|
|
const draftToken = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
|
|
const draft = (await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: draftToken, files: [file("cap.xml", 8)], rating: "general", parent: 0, name: "Cap", notes: "" }, auth)).json.product as string;
|
|
const mine = (await call(maker, "GET", "/api/v1/console/products")).json;
|
|
expect(mine.products.map((p: any) => [p.name, p.status, p.libraries])).toEqual([["Cap", "draft", 0], ["Vest", "published", 1]]);
|
|
expect((await call(maker, "GET", "/api/v1/console")).json.todo.drafts).toBe(1);
|
|
expect((await call(other, "GET", "/api/v1/console/products")).status).toBe(403);
|
|
expect((await call(mod, "GET", "/api/v1/console/products?all=1&status=locked")).json.products.map((p: any) => p.id)).toEqual([id]);
|
|
expect((await call(mod, "GET", "/api/v1/console/products?all=1&status=draft&q=tailor")).json.products.map((p: any) => p.id)).toEqual([draft]);
|
|
|
|
// Drafts never published go after 30 days, and their catalogs are told to delete the files.
|
|
expect(await hub.products.purgeDrafts(new Date(Date.now() + 29 * 24 * 60 * 60 * 1000))).toBe(0);
|
|
expect(await hub.products.purgeDrafts(new Date(Date.now() + 31 * 24 * 60 * 60 * 1000))).toBeGreaterThanOrEqual(1);
|
|
expect((await call(maker, "GET", `/api/v1/products/${draft}`)).status).toBe(404);
|
|
expect((await call(maker, "GET", `/api/v1/products/${id}`)).status).toBe(200);
|
|
const changes = (await call(undefined, "GET", "/api/v1/catalog/changes", undefined, auth)).json.changes;
|
|
expect(changes.find((c: any) => c.product === draft)).toMatchObject({ type: "delete", revision: 1, files: [file("cap.xml", 8)] });
|
|
});
|