Product registry: catalogs, revisions, product info and libraries
CI / test (push) Successful in 43s
CI / test (push) Successful in 43s
The hub registers catalogs (hidden until a moderator approves them), hands out upload tokens, and records the revisions catalogs register after validating uploads: numbered, never changed, signed by the hub (its own Ed25519 key, published at /.well-known/t5de-keys.json). Creators edit product info (descriptions cleaned by an allowlist) and can retract revisions. People get products into their library, optionally staying on a revision; the proxy asks which revision to load and where. Contract in docs/products.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7fea63fc4f
commit
b4f6fce9df
15 files changed
+2883
-14
No files matched your search
@@ -0,0 +1,170 @@
|
||||
// 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, type ImvuUser } from "../src/imvu";
|
||||
|
||||
const webURL = "http://web.test";
|
||||
const apiURL = "http://api.test";
|
||||
const accounts = new Map<number, ImvuUser>();
|
||||
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
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;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, close } = await openDatabase("memory"));
|
||||
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
|
||||
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU), apiURL });
|
||||
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 });
|
||||
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;
|
||||
}
|
||||
|
||||
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}` };
|
||||
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"></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></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)).toEqual([id]);
|
||||
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"]);
|
||||
|
||||
expect((await call(kid, "DELETE", `/api/v1/library/${id}`)).status).toBe(204);
|
||||
expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]);
|
||||
});
|
||||
Reference in new issue
Block a user