// 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(); 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; 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 { 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(); });