// T5DE-hosted rooms (docs/hosted-rooms.md): the REST routes and the realtime room protocol, with fake proxies. import { afterAll, beforeAll, expect, test } from "bun:test"; 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 }; 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") ?? "", }; expect(p.token).not.toBe(""); 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 }); 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 proxy's live connection, signed in to the client as imvuId (0: none). */ 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)); if (imvuId) { send({ type: "session", imvu_id: imvuId }); await Bun.sleep(30); } const last = (type: string, room?: number) => got.filter((m) => m.type === type && (room === undefined || m.room === room)).at(-1); /** Sends a message and waits for the answer of that type for that room. */ const ask = async (m: { type: string; room: number }, answer: (m: any) => boolean) => { const before = got.length; send(m); await waitFor(() => got.slice(before).some(answer)); return got.slice(before).find(answer); }; const join = (room: number) => ask({ type: "room.join", room }, (m) => (m.type === "room.joined" || m.type === "room.error") && m.room === room); return { ws, got, send, last, join, ask }; } async function waitFor(ok: () => boolean) { for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10); expect(ok()).toBe(true); } const newRoom = (imvuId: number, over: Record = {}) => ({ name: "Lounge", description: "A quiet place.", roomPid: 1234, rating: "general", maxUsers: 10, imvuId, ...over, }); test("rooms are made, listed, changed and closed by their owners", async () => { const ada = await person("adam", { id: 301, ageVerified: true }, { id: 302 }); const ben = await person("benn", { id: 311 }); // Validation and permissions. expect((await call(undefined, "POST", "/api/v1/hosted-rooms", newRoom(301))).status).toBe(401); expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { name: " " }))).json.error).toBe("invalid"); expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { maxUsers: 31 }))).status).toBe(400); expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { maxUsers: 1 }))).status).toBe(400); expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { roomPid: 0 }))).status).toBe(400); expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { rating: "x" }))).status).toBe(400); expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { description: "x".repeat(513) }))).status).toBe(400); expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(311))).json.error).toBe("not_linked"); expect((await call(ben, "POST", "/api/v1/hosted-rooms", newRoom(311, { rating: "adult" }))).json.error).toBe("not_adult"); // Made: owned by the chosen account, shown with its IMVU name. const made = await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(302, { name: " The Lounge ", maxUsers: undefined })); expect(made.status).toBe(201); const n = made.json.room.n; expect(made.json.room).toMatchObject({ instanceId: `302-${2_100_000_000 + n}`, chatId: 2_100_000_000 + n, name: "The Lounge", description: "A quiet place.", roomPid: 1234, rating: "general", maxUsers: 10, ownerImvuId: 302, ownerName: "adamAv1", occupancy: 0, }); const adult = (await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { name: "Late", rating: "adult" }))).json.room; expect(adult.rating).toBe("adult"); // Listed newest first; adult rooms only for adults. const names = async (p?: Person, q = "") => ((await call(p, "GET", `/api/v1/hosted-rooms${q}`)).json.rooms as any[]).map((r) => r.name); expect(await names(ada)).toEqual(["Late", "The Lounge"]); expect(await names(ben)).toEqual(["The Lounge"]); expect(await names()).toEqual(["The Lounge"]); expect(await names(ben, "?mine=1")).toEqual([]); expect(await names(ada, "?mine=1")).toEqual(["Late", "The Lounge"]); expect((await call(undefined, "GET", "/api/v1/hosted-rooms?mine=1")).status).toBe(401); // One room, with its scene. const got = await call(ben, "GET", `/api/v1/hosted-rooms/${n}`); expect(got.json.scene.contents).toEqual([]); expect(got.json.scene.hash).toMatch(/^[0-9a-f]{64}$/); expect((await call(ben, "GET", `/api/v1/hosted-rooms/${adult.n}`)).status).toBe(404); expect((await call(ben, "GET", "/api/v1/hosted-rooms/999999")).status).toBe(404); expect((await call(ben, "GET", "/api/v1/hosted-rooms/x")).status).toBe(400); // Changes: owner only. expect((await call(ben, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Mine now" })).status).toBe(403); expect((await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { maxUsers: 40 })).status).toBe(400); const patched = await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Lounge 2", maxUsers: 4, description: "" }); expect(patched.json.room).toMatchObject({ name: "Lounge 2", maxUsers: 4, description: "", roomPid: 1234 }); // The scene: owner only, checked. const scene = { contents: [[1, 5678, "1 0 0 0 0 0 0 1"], [2, "abcdefghij", "1 2 0 0"]] }; expect((await call(ben, "PUT", `/api/v1/hosted-rooms/${n}/scene`, scene)).status).toBe(403); expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, "Nope!", "x"]] })).status).toBe(400); expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, 2]] })).status).toBe(400); expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, 2, "x".repeat(201)]] })).status).toBe(400); const tooMany = { contents: Array.from({ length: 501 }, (_, i) => [i, 2, ""]) }; expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, tooMany)).status).toBe(400); const put = await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { ...scene, roomPid: 4321 }); expect(put.status).toBe(200); expect(put.json.scene.contents).toEqual(scene.contents); expect(put.json.scene.hash).not.toBe(got.json.scene.hash); const after = await call(ben, "GET", `/api/v1/hosted-rooms/${n}`); expect(after.json.scene).toEqual(put.json.scene); expect(after.json.room.roomPid).toBe(4321); // Closing: owner only; then it's gone from lists and answers 410. expect((await call(ben, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(403); expect((await call(ada, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(204); expect((await call(ben, "GET", `/api/v1/hosted-rooms/${n}`)).json.error).toBe("closed"); expect(await names(ada)).toEqual(["Late"]); expect((await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Back" })).status).toBe(410); // Unlinking the owner account hides its rooms. await call(ada, "DELETE", "/api/v1/account/links/301"); expect(await names(ada)).toEqual([]); }); test("a person may own up to 10 open rooms", async () => { const cy = await person("cyd", { id: 321 }); const ns: number[] = []; for (let i = 0; i < 10; i++) { const r = await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321, { name: `Room ${i}` })); expect(r.status).toBe(201); ns.push(r.json.room.n); } expect((await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321))).json.error).toBe("room_cap"); await call(cy, "DELETE", `/api/v1/hosted-rooms/${ns[0]}`); expect((await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321))).status).toBe(201); }); test("joining, seats and messages in a hosted room", async () => { const dee = await person("deen", { id: 401, ageVerified: true }); const eli = await person("elia", { id: 402 }); const fay = await person("faye", { id: 403 }); const room = (await call(dee, "POST", "/api/v1/hosted-rooms", newRoom(401, { maxUsers: 2 }))).json.room; const n: number = room.n; const adultRoom = (await call(dee, "POST", "/api/v1/hosted-rooms", newRoom(401, { rating: "adult" }))).json.room.n; const d = await proxy(dee, 401); const e = await proxy(eli, 402); const f = await proxy(fay, 403); // Refusals: no linked account in use, no such room, adult room for a non-adult. const nobody = await proxy(eli, 0); expect((await nobody.join(n)).reason).toBe("not_linked"); nobody.ws.close(); const notMine = await proxy(eli, 403); // Fay's account, not Eli's expect((await notMine.join(n)).reason).toBe("not_linked"); notMine.ws.close(); expect((await e.join(999_999)).reason).toBe("not_found"); expect((await e.join(adultRoom)).reason).toBe("adult"); e.send({ type: "room.join", room: 0 }); await waitFor(() => e.got.some((m) => m.message === "bad room")); // Dee and Eli join; each sees the other. const dj = await d.join(n); expect(dj).toEqual({ type: "room.joined", room: n, chat: 2_100_000_000 + n, participants: [{ imvu_id: 401, seat: 0, name: "deenAv" }], sequence: 0 }); const ej = await e.join(n); expect(ej.participants).toEqual([{ imvu_id: 401, seat: 0, name: "deenAv" }, { imvu_id: 402, seat: 0, name: "eliaAv" }]); await waitFor(() => d.last("room.participants", n)?.participants.length === 2); expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.occupancy).toBe(2); expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}/participants`)).json.participants).toEqual(ej.participants); expect((await call(eli, "GET", `/api/v1/hosted-rooms/${adultRoom}/participants`)).status).toBe(404); // Full at two people; joining again (a reconnect) answers the same. expect((await f.join(n)).reason).toBe("full"); expect((await e.join(n)).type).toBe("room.joined"); // Seats. e.send({ type: "room.seat", room: n, seat: 3 }); await waitFor(() => d.last("room.participants", n)?.participants.some((p: any) => p.imvu_id === 402 && p.seat === 3)); e.send({ type: "room.seat", room: n, seat: 100 }); await waitFor(() => e.got.some((m) => m.message === "bad seat")); // Messages go to the others, numbered; forged senders are dropped. const chat = (userId: number, message: string) => JSON.stringify({ userId, chatId: 2_100_000_000 + n, message, to: 0 }); e.send({ type: "room.send", room: n, mount: "messages", message: chat(402, "hi") }); e.send({ type: "room.send", room: n, mount: "messages", message: chat(401, "I'm Dee") }); e.send({ type: "room.send", room: n, mount: "messages", message: "not JSON" }); e.send({ type: "room.send", room: n, mount: "control", message: JSON.stringify({ command: "PlayAction" }) }); await waitFor(() => d.got.filter((m) => m.type === "room.message").length === 2); await Bun.sleep(50); const msgs = d.got.filter((m) => m.type === "room.message"); expect(msgs).toEqual([ { type: "room.message", room: n, from: 402, mount: "messages", message: chat(402, "hi"), sequence: 1 }, { type: "room.message", room: n, from: 402, mount: "control", message: JSON.stringify({ command: "PlayAction" }), sequence: 2 }, ]); expect(e.got.filter((m) => m.type === "room.message")).toEqual([]); // not back to the sender e.send({ type: "room.send", room: n, mount: "other", message: "x" }); await waitFor(() => e.got.some((m) => m.message === "bad mount")); e.send({ type: "room.send", room: n, mount: "control", message: "x".repeat(64 * 1024 + 1) }); await waitFor(() => e.got.some((m) => m.message === "bad message")); f.send({ type: "room.send", room: n, mount: "control", message: "{}" }); await waitFor(() => f.got.some((m) => m.message === "not in that room")); // Rate: a burst of 20, then the rest is dropped. for (let i = 0; i < 30; i++) e.send({ type: "room.send", room: n, mount: "control", message: `{"i":${i}}` }); await Bun.sleep(150); const burst = d.got.filter((m) => m.type === "room.message").length - 2; expect(burst).toBeGreaterThanOrEqual(18); expect(burst).toBeLessThanOrEqual(23); expect((await d.join(n)).sequence).toBe(2 + burst); // Leaving makes room; a closed connection leaves too. e.send({ type: "room.leave", room: n }); await waitFor(() => d.last("room.participants", n)?.participants.length === 1); expect((await f.join(n)).type).toBe("room.joined"); f.ws.close(); await waitFor(() => d.last("room.participants", n)?.participants.length === 1); // Closing tells everyone in it; joining it again is refused. expect((await e.join(n)).type).toBe("room.joined"); expect((await call(dee, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(204); await waitFor(() => e.last("room.closed", n) !== undefined && d.last("room.closed", n) !== undefined); expect((await e.join(n)).reason).toBe("closed"); d.ws.close(); e.ws.close(); }); test("a room made adult sends non-adults out; an unlinked account leaves its rooms", async () => { const gil = await person("gilt", { id: 501, ageVerified: true }); const hal = await person("hale", { id: 502 }); const n = (await call(gil, "POST", "/api/v1/hosted-rooms", newRoom(501))).json.room.n; const g = await proxy(gil, 501); const h = await proxy(hal, 502); await g.join(n); await h.join(n); expect((await call(gil, "PATCH", `/api/v1/hosted-rooms/${n}`, { rating: "adult" })).status).toBe(200); await waitFor(() => h.last("room.closed", n) !== undefined); await waitFor(() => g.last("room.participants", n)?.participants.length === 1); expect(g.last("room.closed", n)).toBeUndefined(); await call(gil, "PATCH", `/api/v1/hosted-rooms/${n}`, { rating: "general" }); await h.join(n); await waitFor(() => g.last("room.participants", n)?.participants.length === 2); await call(hal, "DELETE", "/api/v1/account/links/502"); await waitFor(() => h.got.filter((m) => m.type === "room.closed").length === 2); await waitFor(() => g.last("room.participants", n)?.participants.length === 1); g.ws.close(); h.ws.close(); }); // ---- Phase 2 ---- test("moderators boot and ban; owners lift bans", async () => { const own = await person("ownr", { id: 601 }); const mod = await person("modr", { id: 602 }); const usr = await person("usrr", { id: 603 }); const md2 = await person("mod2", { id: 604 }); accounts.set(605, { id: 605, username: "NotOnT5", tagline: "", ageVerified: false, accessPass: false }); // IMVU only const n = (await call(own, "POST", "/api/v1/hosted-rooms", newRoom(601))).json.room.n; // Moderators: IDs or names (linked accounts, else IMVU); the owner's own account is left out; up to 20. expect((await call(mod, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602] })).status).toBe(403); const many = Array.from({ length: 21 }, (_, i) => i + 1); expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: many })).status).toBe(400); expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["nobody_here"] })).json.error).toBe( "no_such_imvu_account", ); const set = await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602, "MOD2av", "notont5", 601, 602] }); expect(set.status).toBe(200); expect(set.json.room.moderators).toEqual([602, 604, 605]); expect(set.json.room.moderatorNames).toEqual(["modrAv", "mod2Av", "NotOnT5"]); await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["602", 604] }); expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.moderators).toEqual([602, 604]); const o = await proxy(own, 601); const m = await proxy(mod, 602); const u = await proxy(usr, 603); const m2 = await proxy(md2, 604); for (const p of [o, m, u, m2]) expect((await p.join(n)).type).toBe("room.joined"); const boot = (p: typeof o, imvu_id: number, extra: object = {}) => p.ask({ type: "room.boot", room: n, imvu_id, ...extra } as any, (x) => x.type === "room.boot_result" && x.imvu_id === imvu_id); // Who may boot whom. expect(await boot(u, 602)).toMatchObject({ ok: false, reason: "forbidden" }); expect((await boot(m, 604)).reason).toBe("forbidden"); // a moderator expect((await boot(m, 601)).reason).toBe("forbidden"); // the owner expect((await boot(o, 601)).reason).toBe("forbidden"); // themselves m.send({ type: "room.boot", room: n, imvu_id: 603, minutes: -1 }); await waitFor(() => m.got.some((x) => x.message === "bad minutes")); // A boot: out, but may come back. expect(await boot(m, 603)).toEqual({ type: "room.boot_result", room: n, imvu_id: 603, ok: true, ban: false }); await waitFor(() => u.last("room.closed", n)?.reason === "booted"); await waitFor(() => o.last("room.participants", n)?.participants.length === 3); expect((await u.join(n)).type).toBe("room.joined"); // A ban: out, and refused. expect((await boot(m, 603, { ban: true })).ban).toBe(true); await waitFor(() => u.got.filter((x) => x.type === "room.closed" && x.reason === "booted").length === 2); expect(await u.join(n)).toMatchObject({ type: "room.error", reason: "banned" }); // Bans: listed for the owner and moderators; lifted by the owner only. expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}/bans`)).status).toBe(403); const bans = await call(mod, "GET", `/api/v1/hosted-rooms/${n}/bans`); expect(bans.json.bans).toMatchObject([{ imvuId: 603, byImvuId: 602, until: null }]); expect((await call(mod, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(403); expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(204); expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(404); expect((await u.join(n)).type).toBe("room.joined"); // A ban for a while; the owner may boot a moderator. await boot(o, 603, { ban: true, minutes: 60 }); const until = (await call(own, "GET", `/api/v1/hosted-rooms/${n}/bans`)).json.bans[0].until; expect(Date.parse(until) - Date.now()).toBeGreaterThan(59 * 60_000); expect((await boot(o, 604)).ok).toBe(true); await waitFor(() => m2.last("room.closed", n)?.reason === "booted"); // Logged. const log = await db.select().from(schema.audit).where(eq(schema.audit.target, String(n))); const actions = log.map((e) => e.action); expect(actions.filter((a) => a === "hosted_room.boot").length).toBe(2); expect(actions.filter((a) => a === "hosted_room.ban").length).toBe(2); expect(actions).toContain("hosted_room.unban"); expect(log.find((e) => e.action === "hosted_room.ban")!.detail).toEqual({ imvu: 603, by: 602, minutes: 0 }); for (const p of [o, m, u, m2]) p.ws.close(); }); test("private rooms and invitations", async () => { const pam = await person("pamm", { id: 701 }); const quin = await person("quin", { id: 702 }); const rex = await person("rexx", { id: 703 }, { id: 705 }); const n = (await call(pam, "POST", "/api/v1/hosted-rooms", newRoom(701, { name: "Hideout", privacy: "private" }))).json.room.n; expect((await call(pam, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.privacy).toBe("private"); await call(pam, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [702] }); const pub = (await call(rex, "POST", "/api/v1/hosted-rooms", newRoom(703, { name: "Open" }))).json.room.n; // Listed only for the owner and moderators. const listed = async (p?: Person, q = "") => ((await call(p, "GET", `/api/v1/hosted-rooms${q}`)).json.rooms as any[]).some((r) => r.n === n); expect(await listed()).toBe(false); expect(await listed(rex)).toBe(false); expect(await listed(pam)).toBe(true); expect(await listed(quin)).toBe(true); expect(await listed(pam, "?mine=1")).toBe(true); expect((await call(rex, "GET", `/api/v1/hosted-rooms/${n}`)).status).toBe(200); // not listed, but there const p = await proxy(pam, 701); const q = await proxy(quin, 702); const r = await proxy(rex, 703); expect((await r.join(n)).reason).toBe("private"); expect((await q.join(n)).type).toBe("room.joined"); const invite = (from: typeof p, room: number, imvu_id: number) => from.ask({ type: "room.invite", room, imvu_id } as any, (x) => x.type === "room.invite_result" && x.room === room && x.imvu_id === imvu_id); const joinWith = (x: typeof p, invite: string) => x.ask({ type: "room.join", room: n, invite } as any, (m) => m.room === n && (m.type === "room.joined" || m.type === "room.error")); // Only the owner and moderators invite to a private room (anyone in it, to a public one). expect(await invite(r, n, 702)).toMatchObject({ ok: false, reason: "forbidden" }); expect(await invite(p, pub, 702)).toMatchObject({ ok: false, reason: "not_in_room" }); // Invited: the invitee's sessions are told; it answers; the inviter hears. const sent = await invite(p, n, 703); expect(sent.ok).toBe(true); const id: string = sent.invite; await waitFor(() => r.last("room.invited", n) !== undefined); expect(r.last("room.invited", n)).toEqual({ type: "room.invited", room: n, invite: id, from: 701, room_name: "Hideout", owner_name: "pammAv", }); expect((await joinWith(r, id)).reason).toBe("private"); // not accepted yet q.send({ type: "room.invite_reply", invite: id, accept: true }); // not Quin's to answer await waitFor(() => q.got.some((x) => x.message === "no such invite")); r.send({ type: "room.invite_reply", invite: id, accept: true, note: " on my way\n" }); await waitFor(() => p.last("room.invite_answer") !== undefined); expect(p.last("room.invite_answer")).toEqual({ type: "room.invite_answer", invite: id, to: 703, accept: true, note: "on my way" }); r.send({ type: "room.invite_reply", invite: id, accept: false }); await waitFor(() => r.got.some((x) => x.message === "already answered")); // An accepted invite lets the account in once; reconnecting is fine, leaving ends it. expect((await joinWith(r, id)).type).toBe("room.joined"); r.ws.close(); const r2 = await proxy(rex, 703); expect((await r2.join(n)).type).toBe("room.joined"); r2.send({ type: "room.leave", room: n }); await waitFor(() => q.last("room.participants", n)?.participants.length === 1); expect((await joinWith(r2, id)).reason).toBe("private"); // Declined. const second = (await invite(q, n, 703)).invite; await waitFor(() => r2.got.some((x) => x.invite === second)); r2.send({ type: "room.invite_reply", invite: second, accept: false }); await waitFor(() => q.got.some((x) => x.type === "room.invite_answer" && x.invite === second)); expect(q.got.find((x) => x.type === "room.invite_answer" && x.invite === second).accept).toBe(false); expect((await joinWith(r2, second)).reason).toBe("private"); // Nobody online as that account; banned accounts aren't invited. const off = await invite(p, n, 705); expect(off.ok).toBe(true); await waitFor(() => p.got.some((x) => x.type === "room.invite_answer" && x.invite === off.invite)); expect(p.got.find((x) => x.type === "room.invite_answer" && x.invite === off.invite)).toEqual({ type: "room.invite_answer", invite: off.invite, to: 705, accept: false, offline: true, }); await p.ask({ type: "room.boot", room: n, imvu_id: 703, ban: true } as any, (x) => x.type === "room.boot_result"); expect(await invite(p, n, 703)).toMatchObject({ ok: false, reason: "banned" }); // Public rooms: anyone in it invites. expect((await r2.join(pub)).type).toBe("room.joined"); expect((await invite(r2, pub, 701)).ok).toBe(true); await waitFor(() => p.last("room.invited", pub) !== undefined); for (const x of [p, q, r2]) x.ws.close(); }); const png = (bytes = 64) => { const b = new Uint8Array(bytes); b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); return b; }; async function upload(p: Person, n: number, data: Uint8Array, type = "image/png") { const form = new FormData(); form.set("image", new File([data as Uint8Array], "room.png", { type })); const res = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, { method: "PUT", headers: { Origin: webURL, Cookie: p.cookie }, body: form, }); const text = await res.text(); return { status: res.status, json: text ? JSON.parse(text) : undefined }; } test("room images", async () => { const ivy = await person("ivyy", { id: 801 }); const jon = await person("jonn", { id: 802 }); const n = (await call(ivy, "POST", "/api/v1/hosted-rooms", newRoom(801))).json.room.n; expect((await call(jon, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.image).toBeNull(); expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404); expect((await upload(jon, n, png())).status).toBe(403); const gif = new TextEncoder().encode("GIF89a................................"); expect((await upload(ivy, n, gif)).json.error).toBe("invalid"); const svg = new TextEncoder().encode(''); expect((await upload(ivy, n, svg)).status).toBe(400); expect((await upload(ivy, n, png(512 * 1024 + 1))).status).toBe(400); const up = await upload(ivy, n, png()); expect(up.status).toBe(200); expect(up.json.room.image).toMatch(new RegExp(`^http://localhost:4101/api/v1/hosted-rooms/${n}/image\\?v=[0-9a-f]{16}$`)); const got = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`); expect(got.status).toBe(200); expect(got.headers.get("content-type")).toBe("image/png"); expect(got.headers.get("cache-control")).toBe("public, max-age=3600"); expect(got.headers.get("x-content-type-options")).toBe("nosniff"); expect(new Uint8Array(await got.arrayBuffer())).toEqual(png()); const etag = got.headers.get("etag")!; expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, { headers: { "If-None-Match": etag } })).status).toBe(304); const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 16, 0x4a, 0x46]); expect((await upload(ivy, n, jpeg)).status).toBe(200); const again = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`); expect(again.headers.get("content-type")).toBe("image/jpeg"); expect(again.headers.get("etag")).not.toBe(etag); expect((await call(jon, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).status).toBe(403); expect((await call(ivy, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).json.room.image).toBeNull(); expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404); }); test("favorites, ratings and recent rooms", async () => { const kim = await person("kimm", { id: 901 }); const lou = await person("louu", { id: 902 }); const mia = await person("miaa", { id: 903 }); const ns: number[] = []; for (const name of ["One", "Two", "Three"]) ns.push((await call(kim, "POST", "/api/v1/hosted-rooms", newRoom(901, { name }))).json.room.n); const [a, b, c] = ns as [number, number, number]; // Favorites. expect((await call(undefined, "GET", "/api/v1/hosted-rooms?favorites=1")).status).toBe(401); expect((await call(undefined, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(401); expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204); expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204); expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204); expect((await call(lou, "PUT", "/api/v1/hosted-rooms/999999/favorite")).status).toBe(404); const favs = async (p: Person) => ((await call(p, "GET", "/api/v1/hosted-rooms?favorites=1")).json.rooms as any[]).map((r) => r.n); expect(await favs(lou)).toEqual([c, a]); expect(await favs(mia)).toEqual([]); const flag = async (p: Person | undefined, n: number) => ((await call(p, "GET", "/api/v1/hosted-rooms")).json.rooms as any[]).find((r) => r.n === n).favorite; expect(await flag(lou, a)).toBe(true); expect(await flag(mia, a)).toBe(false); expect(await flag(undefined, a)).toBe(false); expect((await call(lou, "GET", `/api/v1/hosted-rooms/${a}`)).json.room.favorite).toBe(true); expect((await call(lou, "DELETE", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204); expect(await favs(lou)).toEqual([c]); // Ratings. expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 6 })).status).toBe(400); expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 0 })).status).toBe(400); expect((await call(kim, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 })).status).toBe(403); // own room expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 3 })).json.myRating).toBe(3); expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 4 })).json.room).toMatchObject({ score: 4, ratings: 1 }); await call(mia, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 }); const one = await call(lou, "GET", `/api/v1/hosted-rooms/${a}`); expect(one.json.room).toMatchObject({ score: 4.5, ratings: 2 }); expect(one.json.myRating).toBe(4); expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.myRating).toBe(0); expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${b}`)).json.room).toMatchObject({ score: 0, ratings: 0 }); expect((await call(mia, "DELETE", `/api/v1/hosted-rooms/${a}/rating`)).status).toBe(204); expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.room).toMatchObject({ score: 4, ratings: 1 }); // Recent rooms: joined, latest first, each once. expect((await call(undefined, "GET", "/api/v1/hosted-rooms?recent=1")).status).toBe(401); const l = await proxy(lou, 902); const recent = async () => ((await call(lou, "GET", "/api/v1/hosted-rooms?recent=1")).json.rooms as any[]).map((r) => r.n); for (const n of [a, b, c]) { await l.join(n); await Bun.sleep(20); } for (let i = 0; i < 100 && (await recent()).length < 3; i++) await Bun.sleep(10); expect(await recent()).toEqual([c, b, a]); l.send({ type: "room.leave", room: a }); await Bun.sleep(30); await l.join(a); for (let i = 0; i < 100 && (await recent())[0] !== a; i++) await Bun.sleep(10); expect(await recent()).toEqual([a, c, b]); const visit = await db.select().from(schema.hostedRoomVisit).where(eq(schema.hostedRoomVisit.n, a)); expect(visit.map((v) => v.imvuId)).toEqual([902]); l.ws.close(); }); test("watching rooms' occupancy", async () => { const ned = await person("nedd", { id: 1001 }); const ola = await person("olaa", { id: 1002 }); const a = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "A" }))).json.room.n; const b = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "B" }))).json.room.n; const hidden = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "C", privacy: "private" }))).json.room.n; const w = await proxy(ola, 1002); const n = await proxy(ned, 1001); w.send({ type: "room.watch", rooms: Array.from({ length: 101 }, (_, i) => i + 1) }); await waitFor(() => w.got.some((x) => x.message === "bad rooms")); w.send({ type: "room.watch", rooms: [a, b, hidden, 999_999] }); await waitFor(() => w.last("room.occupancy", a) !== undefined && w.last("room.occupancy", b) !== undefined); expect(w.last("room.occupancy", a)).toEqual({ type: "room.occupancy", room: a, participants: [] }); // The private room's owner may watch it. n.send({ type: "room.watch", rooms: [hidden] }); await waitFor(() => n.last("room.occupancy", hidden) !== undefined); await n.join(a); await waitFor(() => w.last("room.occupancy", a)?.participants.length === 1); expect(w.last("room.occupancy", a).participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]); n.send({ type: "room.seat", room: a, seat: 2 }); await waitFor(() => w.last("room.occupancy", a)?.participants[0]?.seat === 2); n.send({ type: "room.leave", room: a }); await waitFor(() => w.last("room.occupancy", a)?.participants.length === 0); await n.join(hidden); await Bun.sleep(50); expect(w.last("room.occupancy", hidden)).toBeUndefined(); expect(n.last("room.occupancy", hidden)?.participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]); // Replaced; closing tells watchers it's empty. w.send({ type: "room.watch", rooms: [b] }); await Bun.sleep(50); const before = w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length; await n.join(a); await n.join(b); await waitFor(() => w.last("room.occupancy", b)?.participants.length === 1); expect(w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length).toBe(before); await call(ned, "DELETE", `/api/v1/hosted-rooms/${b}`); await waitFor(() => w.last("room.occupancy", b)?.participants.length === 0); w.ws.close(); n.ws.close(); });