CI / test (push) Successful in 1m11s
The header shows T5DE's logo (the client's, also the tab icon) and Online, Rooms, Products and Catalogs; the person's pages (Account, Library, Console for moderators) and signing out are in a menu under their picture: their first linked IMVU account's avatar picture, which /me now gives (kept an hour), or their initial. Creating a product moves to Products (New product, /products/new; /create redirects there), as creating a room is on Rooms. Co-Authored-By: Claude Opus 5.5 <[email protected]>
240 lines
12 KiB
TypeScript
240 lines
12 KiB
TypeScript
// Decorations over IMVU rooms (docs/products.md, "Decorations"): the REST routes and the realtime notice, with fake
|
|
// proxies.
|
|
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
import { createHash } from "node:crypto";
|
|
import { eq, openDatabase, schema, 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 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: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
|
|
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
|
|
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; token: string; id: string };
|
|
|
|
async function call(p: Person | undefined, method: string, path: string, body?: unknown) {
|
|
const res = await fetch(base + path, {
|
|
method,
|
|
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}) },
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
const text = await res.text();
|
|
return { status: res.status, json: text ? JSON.parse(text) : undefined };
|
|
}
|
|
|
|
/** Signs a person up with linked IMVU accounts (adult: age verified on IMVU). */
|
|
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("; "),
|
|
token: res.headers.get("set-auth-token") ?? "",
|
|
id: "",
|
|
};
|
|
expect(p.token).not.toBe("");
|
|
p.id = (await call(p, "GET", "/api/v1/me")).json.person.id;
|
|
for (const [i, a] of imvu.entries()) {
|
|
const username = `${name}Av${i || ""}`;
|
|
accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false, picture: null });
|
|
const started = await call(p, "POST", "/api/v1/account/links", { username });
|
|
accounts.get(a.id)!.tagline = started.json.challenge.code;
|
|
expect((await call(p, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200);
|
|
}
|
|
return p;
|
|
}
|
|
|
|
/** A registered product with one revision (retracted: that revision is). */
|
|
async function product(id: string, creatorId: string, rating: "general" | "adult", retracted = false) {
|
|
await db.insert(schema.product).values({ id, creatorId, name: id, rating, latest: 1 });
|
|
await db.insert(schema.productRevision).values({ productId: id, number: 1, files: [], rating, token: "t", retracted });
|
|
}
|
|
|
|
/** A proxy's live connection, signed in to the client as imvuId. */
|
|
async function proxy(p: Person, imvuId: number) {
|
|
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: { Authorization: `Bearer ${p.token}` } } as any);
|
|
const got: any[] = [];
|
|
ws.onmessage = (e) => got.push(JSON.parse(String(e.data)));
|
|
await new Promise((r) => (ws.onopen = r));
|
|
const send = (m: object) => ws.send(JSON.stringify(m));
|
|
send({ type: "session", imvu_id: imvuId });
|
|
await Bun.sleep(30);
|
|
return { ws, got, send, decor: () => got.filter((m) => m.type === "decor") };
|
|
}
|
|
|
|
async function waitFor(ok: () => boolean) {
|
|
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10);
|
|
expect(ok()).toBe(true);
|
|
}
|
|
|
|
const hash = (v: unknown) => createHash("sha256").update(JSON.stringify(v)).digest("hex");
|
|
|
|
test("only the room's owner decorates it; contents are checked", async () => {
|
|
const owner = await person("dora", { id: 501 }, { id: 502 });
|
|
const other = await person("dave", { id: 511 });
|
|
const unlinked = await person("dina");
|
|
await product("aaaaaaaaaa", owner.id, "general");
|
|
const room = "502-3";
|
|
const item = [7, "aaaaaaaaaa", "0 0 0"];
|
|
|
|
expect((await call(undefined, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(401);
|
|
expect((await call(other, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(403);
|
|
expect((await call(unlinked, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(403);
|
|
|
|
// Validation.
|
|
const put = (contents: unknown, instance = room) => call(owner, "PUT", `/api/v1/decorations/${instance}`, { contents });
|
|
for (const bad of ["502", "0502-3", "502-0", "502-3-4", "x-1", "1234567890123-1"])
|
|
expect((await put([item], bad)).status).toBe(400);
|
|
expect((await put([item], "12345-2100000005")).json.error).toBe("hosted_room");
|
|
expect((await put([[7, "AAAAAAAAAA", ""]])).status).toBe(400);
|
|
expect((await put([[7, 12345, ""]])).status).toBe(400);
|
|
expect((await put([[-1, "aaaaaaaaaa", ""]])).status).toBe(400);
|
|
expect((await put([[7, "aaaaaaaaaa", "x".repeat(201)]])).status).toBe(400);
|
|
expect((await put([[7, "aaaaaaaaaa"]])).status).toBe(400);
|
|
expect((await put([item, item])).status).toBe(400);
|
|
expect((await put(Array.from({ length: 301 }, (_, i) => [i, "aaaaaaaaaa", ""]))).status).toBe(400);
|
|
const unknown = await put([item, [8, "bbbbbbbbbb", ""]]);
|
|
expect(unknown.status).toBe(400);
|
|
expect(unknown.json.error).toBe("unknown_product");
|
|
|
|
// The owner's other account is fine too; up to 300 items.
|
|
const ok = await put(Array.from({ length: 300 }, (_, i) => [i, "aaaaaaaaaa", "0 0 0"]));
|
|
expect(ok.status).toBe(200);
|
|
expect(ok.json.contents).toHaveLength(300);
|
|
const set = await put([item]);
|
|
expect(set.json).toEqual({ instance: room, contents: [item], hash: hash([item]) });
|
|
expect((await call(undefined, "GET", `/api/v1/decorations/${room}`)).json).toEqual(set.json);
|
|
// Not another person's room of the same number.
|
|
expect((await call(owner, "PUT", "/api/v1/decorations/511-3", { contents: [item] })).status).toBe(403);
|
|
});
|
|
|
|
test("viewers see the products they may, with a hash of what they see", async () => {
|
|
const owner = await person("eli", { id: 601, ageVerified: true });
|
|
const kid = await person("emma", { id: 611 });
|
|
const grown = await person("evan", { id: 621, ageVerified: true });
|
|
await product("cccccccccc", owner.id, "general");
|
|
await product("dddddddddd", owner.id, "adult");
|
|
await product("eeeeeeeeee", owner.id, "general", true); // its only revision is retracted
|
|
const room = "601-1";
|
|
const g = [1, "cccccccccc", "a"];
|
|
const a = [2, "dddddddddd", "b"];
|
|
const r = [3, "eeeeeeeeee", "c"];
|
|
|
|
// Unknown rooms have nothing.
|
|
expect((await call(undefined, "GET", "/api/v1/decorations/601-9")).json).toEqual({ instance: "601-9", contents: [], hash: hash([]) });
|
|
expect((await call(undefined, "GET", "/api/v1/decorations/601-x")).status).toBe(400);
|
|
expect((await call(undefined, "GET", "/api/v1/decorations/12345-2100000001")).status).toBe(400);
|
|
|
|
const put = await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [g, a, r] });
|
|
expect(put.status).toBe(200);
|
|
expect(put.json.contents).toEqual([g, a]); // the owner is adult; the retracted one is left out
|
|
|
|
const view = async (p?: Person) => (await call(p, "GET", `/api/v1/decorations/${room}`)).json;
|
|
expect(await view()).toEqual({ instance: room, contents: [g], hash: hash([g]) });
|
|
expect(await view(kid)).toEqual({ instance: room, contents: [g], hash: hash([g]) });
|
|
expect(await view(grown)).toEqual({ instance: room, contents: [g, a], hash: hash([g, a]) });
|
|
|
|
// A product kept but hidden comes back once it has a usable revision again.
|
|
await db.update(schema.productRevision).set({ retracted: false }).where(eq(schema.productRevision.productId, "eeeeeeeeee"));
|
|
expect((await view(kid)).contents).toEqual([g, r]);
|
|
|
|
// Changes change the hash.
|
|
const before = (await view(kid)).hash;
|
|
const moved = [1, "cccccccccc", "moved"];
|
|
await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [moved, a, r] });
|
|
const after = await view(kid);
|
|
expect(after.contents).toEqual([moved, r]);
|
|
expect(after.hash).not.toBe(before);
|
|
|
|
// Several rooms at once, in the order asked.
|
|
const many = await call(grown, "GET", `/api/v1/decorations?instances=601-9,${room}`);
|
|
expect(many.json.rooms).toEqual([
|
|
{ instance: "601-9", contents: [], hash: hash([]) },
|
|
{ instance: room, contents: [moved, a, r], hash: hash([moved, a, r]) },
|
|
]);
|
|
expect((await call(undefined, "GET", "/api/v1/decorations")).json).toEqual({ rooms: [] });
|
|
const tooMany = Array.from({ length: 21 }, (_, i) => `601-${i + 1}`).join(",");
|
|
expect((await call(undefined, "GET", `/api/v1/decorations?instances=${tooMany}`)).status).toBe(400);
|
|
expect((await call(undefined, "GET", "/api/v1/decorations?instances=601-1,nope")).status).toBe(400);
|
|
|
|
// An empty list removes them.
|
|
const cleared = await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
|
|
expect(cleared.json).toEqual({ instance: room, contents: [], hash: hash([]) });
|
|
expect(await db.select().from(schema.roomDecoration).where(eq(schema.roomDecoration.instanceId, room))).toHaveLength(0);
|
|
});
|
|
|
|
test("proxies in the room are told when its decorations change", async () => {
|
|
const owner = await person("fay", { id: 701 });
|
|
const guest = await person("finn", { id: 711 });
|
|
const away = await person("fred", { id: 721 });
|
|
await product("ffffffffff", owner.id, "general");
|
|
const room = "701-2";
|
|
|
|
const host = await proxy(owner, 701); // the owner's client, in the room
|
|
const editor = await proxy(owner, 701); // the owner's other session, saving (not in the room)
|
|
const inRoom = await proxy(guest, 711);
|
|
const elsewhere = await proxy(away, 721);
|
|
host.send({ type: "join", chat: "1400000001", room });
|
|
inRoom.send({ type: "join", chat: "1400000001", room });
|
|
elsewhere.send({ type: "join", chat: "1400000002", room: "701-3" });
|
|
elsewhere.send({ type: "join", chat: "1400000003" });
|
|
await Bun.sleep(50);
|
|
|
|
expect((await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [[1, "ffffffffff", ""]] })).status).toBe(200);
|
|
await waitFor(() => host.decor().length === 1 && inRoom.decor().length === 1);
|
|
expect(inRoom.decor()[0]).toEqual({ type: "decor", room });
|
|
await Bun.sleep(50);
|
|
expect(editor.decor()).toHaveLength(0);
|
|
expect(elsewhere.decor()).toHaveLength(0);
|
|
|
|
// Not after leaving.
|
|
inRoom.send({ type: "leave", chat: "1400000001" });
|
|
await Bun.sleep(30);
|
|
await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
|
|
await waitFor(() => host.decor().length === 2);
|
|
await Bun.sleep(50);
|
|
expect(inRoom.decor()).toHaveLength(1);
|
|
|
|
// A refused change tells no one.
|
|
await call(guest, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
|
|
await Bun.sleep(50);
|
|
expect(host.decor()).toHaveLength(2);
|
|
|
|
for (const p of [host, editor, inRoom, elsewhere]) p.ws.close();
|
|
});
|
|
|