CI / test (push) Successful in 58s
The client takes a room's owner from the instance ID's first part; with 2100000000 there, the owner couldn't decorate their hosted room (putting furniture on went to their home room instead). Co-Authored-By: Claude Opus 5.5 <[email protected]>
323 lines
16 KiB
TypeScript
323 lines
16 KiB
TypeScript
// 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 { 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 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 };
|
|
|
|
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") ?? "",
|
|
};
|
|
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 };
|
|
}
|
|
|
|
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<string, unknown> = {}) => ({
|
|
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 }], sequence: 0 });
|
|
const ej = await e.join(n);
|
|
expect(ej.participants).toEqual([{ imvu_id: 401, seat: 0 }, { imvu_id: 402, seat: 0 }]);
|
|
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);
|
|
|
|
// 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();
|
|
});
|