Phase 2 of the plan in `docs/design.md` ("Next"): product info.
**Drafts**
- A new upload is a draft. Only its creator and moderators can see it, and nobody can get or wear it.
- After uploading, the website goes straight to the product's review page, where the creator checks its info and presses Publish.
- Drafts not published within 30 days are deleted (checked every 10 minutes), and their catalogs get `delete` entries in their changes feed.
- The console's overview shows a "Drafts" to-do.
**Editing is a permission check now** (`product:edit`, from #7)
- Moderators can edit anyone's product. They must give a reason, which the creator sees.
- Moderators can lock fields (`product:lock`). Once a field is locked, only people with `product:edit-locked` can change it, so the creator sees it greyed out with "Locked by a moderator."
- Raising a rating to adult is immediate for anyone who can edit. Lowering it takes `product:rate-lower` (moderators), and is refused while any revision's files are rated adult. With cases (phase 3), a creator's request to lower it will open a rating review.
**Info history**
- Every edit, publish, lock change and undo is recorded with who, when, what changed (before and after) and why.
- It's shown on the edit page to the creator and moderators.
- "Undo" puts back what an entry changed, as a new entry, by the same rules (a creator can't undo a moderator's change to a field that's now locked).
- Moderator actions are also in the creator's audit log.
**Unlisted products and tags**
- An unlisted product is left out of search, lists and creator pages, except for its creator. Its link still works and people can still get it.
- Tags: up to 10 per product, lowercased.
- `/products` filters by `tag` and `catalog`, and a product's tags link to the filtered list.
- People who don't manage a product don't see its state labels (draft, unlisted, locks).
**Console → Products**
- Lists your products with drafts included; moderators can switch to everyone's.
- Filters: draft, published, unlisted, locked, deleted, removed, plus search.
- Shows how many libraries each product is in, and an Edit or Review button.
**Category**
- A product can be filed under a different IMVU category from its parent's.
- The choices are every category path the hub has seen on any product's parent, plus each level of those paths. They're limited to the same top-level category as the parent's, so a product stays the same kind (clothing, furniture or rooms), which the proxy relies on for the shop and inventory tabs.
- The list grows as products are uploaded. IMVU's own category tree, if it has a public one, would replace it.
- Category works like the other info fields: it can be locked, it's in the history, and it can be undone. The product list's category filter, and everything that shows a product's categories (including what the proxy gets), use the chosen path.
**Migration** `0011_product_info`: new product columns and the `product_info_change` table. Existing products count as published (with `published_at` set from their creation date) and listed.
**Compatibility**
- `t5de-catalog` needs no change: the hub's new `draft` field in the registration answer is ignored, and the website doesn't rely on it.
- The proxy only loads products from libraries, and drafts can't be added to one.
**Checks**
- 44 API tests pass, including a new one for moderator edits, locks, history, undo, unlisted, tags, the console list and draft purging, and new checks for category choices.
- Type checks pass.
- On a local hub I loaded the pages as the creator, an admin, another person and a signed-out visitor.
- I submitted the forms through the website, the way a browser does without JavaScript: save info, publish, choosing a category (and undoing it), a moderator's save with and without a reason, save locks, and undo, including a creator's undo refused by a lock. Each saved or refused correctly with the right message.
- Not tried: the upload form's redirect after a real upload to a running catalog.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Phase 2 of the plan in `docs/design.md` ("Next"): product info.
**Drafts**
- A new upload is a draft. Only its creator and moderators can see it, and nobody can get or wear it.
- After uploading, the website goes straight to the product's review page, where the creator checks its info and presses Publish.
- Drafts not published within 30 days are deleted (checked every 10 minutes), and their catalogs get `delete` entries in their changes feed.
- The console's overview shows a "Drafts" to-do.
**Editing is a permission check now** (`product:edit`, from #7)
- Moderators can edit anyone's product. They must give a reason, which the creator sees.
- Moderators can lock fields (`product:lock`). Once a field is locked, only people with `product:edit-locked` can change it, so the creator sees it greyed out with "Locked by a moderator."
- Raising a rating to adult is immediate for anyone who can edit. Lowering it takes `product:rate-lower` (moderators), and is refused while any revision's files are rated adult. With cases (phase 3), a creator's request to lower it will open a rating review.
**Info history**
- Every edit, publish, lock change and undo is recorded with who, when, what changed (before and after) and why.
- It's shown on the edit page to the creator and moderators.
- "Undo" puts back what an entry changed, as a new entry, by the same rules (a creator can't undo a moderator's change to a field that's now locked).
- Moderator actions are also in the creator's audit log.
**Unlisted products and tags**
- An unlisted product is left out of search, lists and creator pages, except for its creator. Its link still works and people can still get it.
- Tags: up to 10 per product, lowercased.
- `/products` filters by `tag` and `catalog`, and a product's tags link to the filtered list.
- People who don't manage a product don't see its state labels (draft, unlisted, locks).
**Console → Products**
- Lists your products with drafts included; moderators can switch to everyone's.
- Filters: draft, published, unlisted, locked, deleted, removed, plus search.
- Shows how many libraries each product is in, and an Edit or Review button.
**Category**
- A product can be filed under a different IMVU category from its parent's.
- The choices are every category path the hub has seen on any product's parent, plus each level of those paths. They're limited to the same top-level category as the parent's, so a product stays the same kind (clothing, furniture or rooms), which the proxy relies on for the shop and inventory tabs.
- The list grows as products are uploaded. IMVU's own category tree, if it has a public one, would replace it.
- Category works like the other info fields: it can be locked, it's in the history, and it can be undone. The product list's category filter, and everything that shows a product's categories (including what the proxy gets), use the chosen path.
**Migration** `0011_product_info`: new product columns and the `product_info_change` table. Existing products count as published (with `published_at` set from their creation date) and listed.
**Compatibility**
- `t5de-catalog` needs no change: the hub's new `draft` field in the registration answer is ignored, and the website doesn't rely on it.
- The proxy only loads products from libraries, and drafts can't be added to one.
**Checks**
- 44 API tests pass, including a new one for moderator edits, locks, history, undo, unlisted, tags, the console list and draft purging, and new checks for category choices.
- Type checks pass.
- On a local hub I loaded the pages as the creator, an admin, another person and a signed-out visitor.
- I submitted the forms through the website, the way a browser does without JavaScript: save info, publish, choosing a category (and undoing it), a moderator's save with and without a reason, save locks, and undo, including a creator's undo refused by a lock. Each saved or refused correctly with the right message.
- Not tried: the upload form's redirect after a real upload to a running catalog.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: dhkatz <[email protected]>
340 lines
18 KiB
TypeScript
340 lines
18 KiB
TypeScript
// Managing catalogs (docs/products.md) with permissions (docs/console.md): settings, keys, teams, membership and
|
|
// what it gates, invites, delisting, suspension, the changes feed, and the console's scoped audit log.
|
|
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
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));
|
|
if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 });
|
|
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;
|
|
|
|
// Catalog addresses that answer /health (for address changes).
|
|
const healthy = new Set<string>(["https://moved.example.test"]);
|
|
|
|
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,
|
|
webURL,
|
|
catalogHealthy: async (url) => healthy.has(url),
|
|
});
|
|
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; name: 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): 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 }),
|
|
});
|
|
return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name };
|
|
}
|
|
|
|
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
|
|
|
|
/** A person's catalog, registered and approved; its key's Authorization header. */
|
|
async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") {
|
|
const reg = await call(owner, "POST", "/api/v1/catalogs", {
|
|
name,
|
|
url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`,
|
|
contact: "[email protected]",
|
|
visibility: "public",
|
|
membership,
|
|
});
|
|
expect(reg.status).toBe(201);
|
|
const id = reg.json.catalog.id as string;
|
|
expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200);
|
|
return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } };
|
|
}
|
|
|
|
/** A product uploaded by a person to a catalog; its ID. */
|
|
async function upload(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, n: number, rating = "general") {
|
|
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
|
|
expect(tok.status).toBe(200);
|
|
const r = await call(
|
|
undefined,
|
|
"POST",
|
|
"/api/v1/catalog/revisions",
|
|
{ uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" },
|
|
c.auth,
|
|
);
|
|
expect(r.status).toBe(201);
|
|
expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200);
|
|
return r.json.product as string;
|
|
}
|
|
|
|
let admin: Person;
|
|
|
|
test("the first account is the admin", async () => {
|
|
admin = await person("admin1");
|
|
expect((await call(admin, "GET", "/api/v1/me")).json.person.role).toBe("admin");
|
|
});
|
|
|
|
test("a catalog's owner changes its settings and key; others can't, and are told which permission they lack", async () => {
|
|
const owner = await person("owner1");
|
|
const stranger = await person("stranger1");
|
|
const c = await catalogOf(owner, admin, "Owner Shelf");
|
|
|
|
// Anyone can see an approved catalog; the owner sees their permissions there.
|
|
const seen = await call(owner, "GET", `/api/v1/catalogs/${c.id}`);
|
|
expect(seen.status).toBe(200);
|
|
expect(seen.json.roles).toContain("catalog-owner");
|
|
expect(seen.json.permissions).toContain("catalog:edit");
|
|
expect((await call(stranger, "GET", `/api/v1/catalogs/${c.id}`)).json.permissions).toEqual([]);
|
|
|
|
// A stranger is refused, naming what's missing.
|
|
const refused = await call(stranger, "PATCH", `/api/v1/catalogs/${c.id}`, { name: "Mine now" });
|
|
expect(refused.status).toBe(403);
|
|
expect(refused.json).toMatchObject({ error: "forbidden", missing: { permission: "catalog:edit", scope: { catalog: c.id } } });
|
|
|
|
// The owner changes settings; an address must answer /health.
|
|
const edited = await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { name: "Owner's Shelf", maxRating: "general", uploads: "members" });
|
|
expect(edited.status).toBe(200);
|
|
expect(edited.json.catalog).toMatchObject({ name: "Owner's Shelf", maxRating: "general", uploads: "members" });
|
|
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { url: "https://nowhere.example.test" })).json.error).toBe("unreachable");
|
|
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { url: "https://moved.example.test/" })).json.catalog.url).toBe(
|
|
"https://moved.example.test",
|
|
);
|
|
|
|
// A new key: the old one stops working at once.
|
|
const rotated = await call(owner, "POST", `/api/v1/catalogs/${c.id}/key`);
|
|
expect(rotated.status).toBe(200);
|
|
expect((await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth)).status).toBe(401);
|
|
expect(
|
|
(await call(undefined, "GET", "/api/v1/catalog/changes", undefined, { Authorization: `Catalog ${c.id}:${rotated.json.key}` })).status,
|
|
).toBe(200);
|
|
});
|
|
|
|
test("teams: the owner gives the operator role; operators manage members but not the team", async () => {
|
|
const owner = await person("owner2");
|
|
const op = await person("op2");
|
|
const other = await person("other2");
|
|
const c = await catalogOf(owner, admin, "Team Shelf", "approval");
|
|
|
|
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/team/other2`, { role: "catalog-operator" })).status).toBe(403);
|
|
const added = await call(owner, "PUT", `/api/v1/catalogs/${c.id}/team/op2`, { role: "catalog-operator" });
|
|
expect(added.status).toBe(200);
|
|
expect(added.json.team.roles).toMatchObject([{ username: "op2", role: "catalog-operator" }]);
|
|
|
|
// The operator holds member:approve, not team:manage.
|
|
const perms = (await call(op, "GET", `/api/v1/console/permissions?catalog=${c.id}`)).json;
|
|
expect(perms.roles).toEqual(["catalog-operator"]);
|
|
expect(perms.permissions).toContain("member:approve");
|
|
expect(perms.permissions).not.toContain("team:manage");
|
|
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/team/other2`, { role: "catalog-operator" })).json.missing.permission).toBe(
|
|
"team:manage",
|
|
);
|
|
|
|
// An approval catalog: other asks, the operator approves.
|
|
expect((await call(other, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("requested");
|
|
const requests = await call(op, "GET", `/api/v1/catalogs/${c.id}/members?status=requested`);
|
|
expect(requests.json.members).toMatchObject([{ username: "other2", status: "requested" }]);
|
|
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/members/other2`, { status: "member" })).status).toBe(200);
|
|
expect((await call(other, "GET", `/api/v1/catalogs/${c.id}`)).json.membership).toBe("member");
|
|
|
|
// Operators leave on their own; the console lists the catalog for them while they're on the team.
|
|
expect((await call(op, "GET", "/api/v1/console/catalogs")).json.catalogs.map((x: any) => x.id)).toEqual([c.id]);
|
|
expect((await call(op, "DELETE", `/api/v1/catalogs/${c.id}/team/op2?role=catalog-operator`)).status).toBe(200);
|
|
expect((await call(op, "GET", "/api/v1/console/catalogs")).status).toBe(403);
|
|
});
|
|
|
|
test("membership gates getting and wearing products; seeing them doesn't need it", async () => {
|
|
const owner = await person("owner3");
|
|
const maker = await person("maker3");
|
|
const fan = await person("fan3");
|
|
const c = await catalogOf(owner, admin, "Invite Shelf", "invite");
|
|
// Uploads to an invite catalog: anyone may upload by default.
|
|
const pid = await upload(maker, c, "Hat", 31);
|
|
|
|
// Seeing works; getting doesn't, and says which catalogs host it.
|
|
const page = await call(fan, "GET", `/api/v1/products/${pid}`);
|
|
expect(page.json).toMatchObject({ available: false, catalogs: [{ id: c.id, membership: "invite", joined: null }] });
|
|
expect((await call(fan, "GET", `/api/v1/products/${pid}/files`)).status).toBe(200);
|
|
const refused = await call(fan, "PUT", `/api/v1/library/${pid}`);
|
|
expect(refused.status).toBe(403);
|
|
expect(refused.json).toMatchObject({ error: "members_only", catalogs: [{ id: c.id }] });
|
|
|
|
// Invite only: joining without an invite is refused; with one, they're in.
|
|
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.error).toBe("invite_only");
|
|
const inv = await call(owner, "POST", `/api/v1/catalogs/${c.id}/invites`, { maxUses: 1 });
|
|
expect(inv.status).toBe(201);
|
|
expect(inv.json.invite.url).toBe(`${webURL}/catalogs/${c.id}?invite=${inv.json.invite.code}`);
|
|
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, { invite: inv.json.invite.code })).json.membership).toBe("member");
|
|
const late = await person("late3");
|
|
expect((await call(late, "POST", `/api/v1/catalogs/${c.id}/join`, { invite: inv.json.invite.code })).json.error).toBe("invite_invalid");
|
|
|
|
expect((await call(fan, "PUT", `/api/v1/library/${pid}`)).status).toBe(200);
|
|
expect((await call(fan, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([pid]);
|
|
|
|
// Banned: the product stays in the table but isn't listed (or wearable) until they're back.
|
|
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/fan3`, { status: "banned" })).status).toBe(200);
|
|
expect((await call(fan, "GET", "/api/v1/library")).json.library).toEqual([]);
|
|
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.error).toBe("banned");
|
|
expect((await call(owner, "DELETE", `/api/v1/catalogs/${c.id}/members/fan3`)).status).toBe(204);
|
|
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/fan3`, { status: "member" })).status).toBe(200);
|
|
expect((await call(fan, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([pid]);
|
|
|
|
// Its creator always has their own product.
|
|
expect((await call(maker, "PUT", `/api/v1/library/${pid}`)).status).toBe(200);
|
|
});
|
|
|
|
test("a catalog's upload policy and rating ceiling apply to uploads", async () => {
|
|
const owner = await person("owner4");
|
|
const maker = await person("maker4");
|
|
const c = await catalogOf(owner, admin, "Strict Shelf");
|
|
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { uploads: "members", maxRating: "general" })).status).toBe(200);
|
|
|
|
const refused = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
|
|
expect(refused).toMatchObject({ status: 403, json: { error: "forbidden" } });
|
|
expect((await call(maker, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("member");
|
|
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
|
|
expect(tok.status).toBe(200);
|
|
const adult = await call(
|
|
undefined,
|
|
"POST",
|
|
"/api/v1/catalog/revisions",
|
|
{ uploadToken: tok.json.token, files: [file("index.xml", 41)], rating: "adult", parent: 0, name: "Spicy", notes: "" },
|
|
c.auth,
|
|
);
|
|
expect(adult).toMatchObject({ status: 400, json: { error: "rating_ceiling" } });
|
|
|
|
// Team only: members can't either.
|
|
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { uploads: "team" })).status).toBe(200);
|
|
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(403);
|
|
expect((await call(owner, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(200);
|
|
});
|
|
|
|
test("delisting and suspension reach the catalog through its changes feed", async () => {
|
|
const owner = await person("owner5");
|
|
const maker = await person("maker5");
|
|
const c = await catalogOf(owner, admin, "Feed Shelf");
|
|
const pid = await upload(maker, c, "Scarf", 51);
|
|
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}/products`)).json.products).toMatchObject([{ id: pid, revisions: [1] }]);
|
|
|
|
expect((await call(maker, "DELETE", `/api/v1/catalogs/${c.id}/products/${pid}?reason=no`)).status).toBe(403);
|
|
const delisted = await call(owner, "DELETE", `/api/v1/catalogs/${c.id}/products/${pid}?reason=${encodeURIComponent("off-topic")}`);
|
|
expect(delisted.json.revisions).toEqual([1]);
|
|
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}/products`)).json.products).toEqual([]);
|
|
|
|
const feed = await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth);
|
|
expect(feed.json.changes).toEqual([{ type: "delete", product: pid, revision: 1, files: [file("index.xml", 51)] }]);
|
|
const cursor = feed.json.cursor;
|
|
expect((await call(undefined, "GET", `/api/v1/catalog/changes?after=${cursor}`, undefined, c.auth)).json.changes).toEqual([]);
|
|
|
|
// Owners can't suspend their own catalog; moderators can. A suspended catalog can't register revisions, still
|
|
// reads its feed, and is told.
|
|
expect((await call(owner, "POST", `/api/v1/catalogs/${c.id}/suspend`, { reason: "x" })).status).toBe(403);
|
|
expect((await call(admin, "POST", `/api/v1/catalogs/${c.id}/suspend`, { reason: "spam" })).json.catalog).toMatchObject({
|
|
status: "suspended",
|
|
statusReason: "spam",
|
|
});
|
|
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(404);
|
|
const told = await call(undefined, "GET", `/api/v1/catalog/changes?after=${cursor}`, undefined, c.auth);
|
|
expect(told.json.changes).toEqual([{ type: "status", status: "suspended" }]);
|
|
expect((await call(admin, "POST", `/api/v1/catalogs/${c.id}/reinstate`)).json.catalog.status).toBe("approved");
|
|
});
|
|
|
|
test("the console is for everyone who controls something; its audit log shows only what they may see", async () => {
|
|
const owner = await person("owner6");
|
|
const maker = await person("maker6");
|
|
const nobody = await person("nobody6");
|
|
const c = await catalogOf(owner, admin, "Audit Shelf");
|
|
const other = await catalogOf(await person("owner6b"), admin, "Other Shelf");
|
|
const pid = await upload(maker, c, "Gloves", 61);
|
|
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { contact: "[email protected]" })).status).toBe(200);
|
|
expect((await call(maker, "PATCH", `/api/v1/products/${pid}`, { name: "Warm Gloves" })).status).toBe(200);
|
|
expect((await call(admin, "PATCH", `/api/v1/catalogs/${other.id}`, { name: "Other Shelf 2" })).status).toBe(200);
|
|
|
|
// Sections by what they control.
|
|
expect((await call(nobody, "GET", "/api/v1/console")).status).toBe(403);
|
|
expect((await call(nobody, "GET", "/api/v1/console/audit")).status).toBe(403);
|
|
const ownerConsole = (await call(owner, "GET", "/api/v1/console")).json;
|
|
expect(ownerConsole.sections).toEqual(["overview", "catalogs", "audit"]);
|
|
expect(ownerConsole.moderation).toBeNull();
|
|
expect((await call(maker, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "audit"]);
|
|
expect((await call(admin, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "catalogs", "people", "audit", "settings"]);
|
|
|
|
// The owner sees their catalog's entries, not the other catalog's; the maker sees their product's.
|
|
const ownerLog = (await call(owner, "GET", "/api/v1/console/audit")).json.entries;
|
|
expect(ownerLog.some((e: any) => e.action === "catalog.edit" && e.tags.catalog === c.id)).toBe(true);
|
|
expect(ownerLog.some((e: any) => e.tags.catalog === other.id)).toBe(false);
|
|
expect(ownerLog.find((e: any) => e.tags.catalog === c.id).tagNames[c.id]).toBe("Audit Shelf");
|
|
const makerLog = (await call(maker, "GET", "/api/v1/console/audit")).json.entries;
|
|
expect(makerLog.map((e: any) => e.action)).toContain("product.edit");
|
|
expect(makerLog.some((e: any) => e.tags.catalog === c.id && e.action === "catalog.edit")).toBe(false);
|
|
|
|
// Admins see everything, and filter.
|
|
const filtered = (await call(admin, "GET", `/api/v1/console/audit?catalog=${other.id}&action=catalog.edit`)).json.entries;
|
|
expect(filtered).toHaveLength(1);
|
|
expect(filtered[0]).toMatchObject({ actor: "admin1", action: "catalog.edit" });
|
|
expect((await call(admin, "GET", "/api/v1/console/audit?actor=maker6")).json.entries.every((e: any) => e.actor === "maker6")).toBe(true);
|
|
expect((await call(admin, "GET", "/api/v1/console/audit?q=legal@example")).json.entries).toHaveLength(1);
|
|
});
|
|
|
|
test("outfits relay only the hub products the wearer may wear; local packs pass", async () => {
|
|
const { Realtime } = await import("../src/realtime");
|
|
const rt = new Realtime({
|
|
owner: async () => "u1",
|
|
wearable: async (_, ids) => new Set(ids.filter((id) => id === "aaaaaaaaaa")),
|
|
});
|
|
rt.open("s1", "u1", () => {});
|
|
await rt.message("s1", { type: "session", imvu_id: 7 });
|
|
await rt.message("s1", {
|
|
type: "outfit",
|
|
items: [
|
|
{ id: "aaaaaaaaaa", name: "Mine", adult: false },
|
|
{ id: "bbbbbbbbbb", name: "Members only", adult: false },
|
|
{ id: "local-pack-1", name: "A pack", adult: false },
|
|
],
|
|
});
|
|
expect(rt.online()[0]!.outfit.map((i) => i.id)).toEqual(["aaaaaaaaaa", "local-pack-1"]);
|
|
});
|