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]>
434 lines
28 KiB
TypeScript
434 lines
28 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);
|
|
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);
|
|
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);
|
|
|
|
// 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.error).toBe("review_needed");
|
|
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)] });
|
|
});
|