Files
t5de-hub/apps/api/test/hub.test.ts
T
dhkatzandClaude Opus 5.5 6ba4d83317
CI / test (push) Successful in 6m38s
Room channel and rooms list
The realtime connection now carries the IMVU chats a client is in: the hub tells each proxy in a chat which
other T5DE users are there and what community products they wear (adult products only to adult accounts), on
joining and on every change. Rooms T5DE users enter are looked up on IMVU; those that are public, listed and
whose owner turned on "List my public rooms" appear on the new Rooms page, with the T5DE users in them who
show they're online. AP rooms are shown to adults only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:14:43 -07:00

257 lines
12 KiB
TypeScript

// The whole API against an in-process Postgres (PGlite) and a fake IMVU.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth, proxyClientID } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const accounts = new Map<number, ImvuUser>();
// IMVU's public user API, answering from accounts.
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const found = [...accounts.values()].find(
(u) =>
url.pathname === `/user/user-${u.id}` ||
(url.pathname === "/user" && url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase()),
);
const denormalized = found
? {
[`https://api.imvu.com/user/user-${found.id}`]: {
data: {
legacy_cid: found.id,
username: found.username,
tagline: found.tagline,
is_ageverified: found.ageVerified,
is_ap: found.accessPass,
},
},
}
: {};
return Response.json({ status: "success", denormalized });
}) as typeof fetch;
let db: Database;
let close: () => Promise<void>;
let hub: ReturnType<typeof createApp>;
let server: { port: number; stop: () => void } | undefined;
let base: string;
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 });
hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
server = hub.app.server as unknown as { port: number; stop: () => void };
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
server?.stop();
await close();
});
/** A person using the website: keeps the session cookie. */
class Browser {
cookie = "";
async call(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" } : {}),
...(this.cookie ? { Cookie: this.cookie } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const set = res.headers.getSetCookie();
if (set.length) this.cookie = set.map((c) => c.split(";")[0]).join("; ");
const text = await res.text();
let json: any;
try {
json = JSON.parse(text);
} catch {
json = text;
}
return { status: res.status, json };
}
async signup(username: string) {
const r = await this.call("POST", "/api/auth/sign-up/email", {
email: `${username.toLowerCase()}@example.test`,
password: "a long password",
name: username,
username,
});
expect(r.status).toBe(200);
return r;
}
}
/** Links an IMVU account through the API, putting the code in its fake tagline. */
async function link(b: Browser, u: Omit<ImvuUser, "tagline">) {
accounts.set(u.id, { ...u, tagline: "" });
const started = await b.call("POST", "/api/v1/account/links", { username: u.username });
expect(started.status).toBe(201);
accounts.set(u.id, { ...u, tagline: `hi ${started.json.challenge.code.toLowerCase()}` });
return b.call("POST", `/api/v1/account/links/${u.id}/check`);
}
describe("accounts", () => {
test("the first account is the admin; usernames sign in", async () => {
const alice = new Browser();
await alice.signup("alice");
expect((await alice.call("GET", "/api/v1/me")).json.person.role).toBe("admin");
const bob = new Browser();
await bob.signup("bob");
expect((await bob.call("GET", "/api/v1/me")).json.person.role).toBe("user");
expect((await bob.call("GET", "/api/v1/console")).status).toBe(403);
const again = new Browser();
const wrong = await again.call("POST", "/api/auth/sign-in/username", { username: "bob", password: "nope nope nope" });
expect(wrong.status).toBe(401);
const right = await again.call("POST", "/api/auth/sign-in/username", { username: "BOB", password: "a long password" });
expect(right.status).toBe(200);
expect((await again.call("GET", "/api/v1/me")).json.person.username).toBe("bob");
const taken = await new Browser().call("POST", "/api/auth/sign-up/email", {
email: "[email protected]",
password: "a long password",
name: "Alice",
username: "ALICE",
});
expect(taken.status).toBeGreaterThanOrEqual(400);
expect((await new Browser().call("GET", "/api/v1/me")).status).toBe(401);
});
});
describe("linking IMVU accounts", () => {
test("by tagline; the most recent proof wins; link caps", async () => {
const carol = new Browser();
await carol.signup("carol");
accounts.set(42, { id: 42, username: "CarolAv", tagline: "", ageVerified: true, accessPass: false });
const started = await carol.call("POST", "/api/v1/account/links", { username: "carolav" });
expect(started.json.challenge.code).toMatch(/^t5de-[A-Z2-9]{4}-[A-Z2-9]{4}$/);
const missing = await carol.call("POST", "/api/v1/account/links/42/check");
expect(missing.status).toBe(409);
expect(missing.json.error).toBe("code_missing");
const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false });
expect(linked.status).toBe(200);
expect(linked.json.link).toMatchObject({ imvuId: 42, username: "CarolAv", adult: true });
expect((await carol.call("POST", "/api/v1/account/links", { username: "nobody" })).json.error).toBe(
"no_such_imvu_account",
);
// Dave proves the same account is his: it moves, with a new pairwise ID.
const before = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
const dave = new Browser();
await dave.signup("dave");
const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false });
expect(moved.json.moved).toBe(true);
const after = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
expect(after.userId).not.toBe(before.userId);
expect(after.pairwiseId).not.toBe(before.pairwiseId);
expect((await carol.call("GET", "/api/v1/account")).json.links).toHaveLength(0);
// A moderator lowers Dave's link cap to 1: a second account is refused.
const admin = new Browser();
await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" });
const daveId = (await dave.call("GET", "/api/v1/me")).json.person.id;
expect((await admin.call("POST", `/api/v1/console/people/${daveId}/link-cap`, { linkCap: 1 })).status).toBe(200);
const over = await link(dave, { id: 43, username: "DaveAlt", ageVerified: false, accessPass: false });
expect(over.status).toBe(403);
expect(over.json.error).toBe("link_cap");
});
});
describe("T5DE's proxy", () => {
test("signs in with a device code, connects, shows presence; a ban ends it", async () => {
const erin = new Browser();
await erin.signup("erin");
await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false });
// The proxy asks for a code; until it's approved, the token request waits.
const codeRes = await fetch(`${base}/api/auth/device/code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: proxyClientID }),
});
const device = await codeRes.json();
expect(device.verification_uri).toBe(`${webURL}/device`);
const poll = async () =>
(
await fetch(`${base}/api/auth/device/token`, {
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": "t5de-proxy/test" },
body: JSON.stringify({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device.device_code,
client_id: proxyClientID,
}),
})
).json();
expect((await poll()).error).toBe("authorization_pending");
const userCode = device.user_code as string;
const approved = await erin.call("POST", "/api/v1/device/approve", {
code: userCode.toLowerCase().slice(0, 4) + "-" + userCode.toLowerCase().slice(4),
});
expect(approved.status).toBe(204);
await Bun.sleep(3100); // the proxy keeps to the polling interval
const tokens = await poll();
expect(tokens.access_token).toBeString();
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
const me = await fetch(`${base}/api/v1/me`, { headers: bearer });
expect((await me.json()).person.username).toBe("erin");
// The live connection: another person's account is refused; Erin's shows online with her outfit, to moderators,
// and to everyone once she opts in.
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: bearer } as any);
const messages: any[] = [];
ws.onmessage = (e) => messages.push(JSON.parse(String(e.data)));
await new Promise((r) => (ws.onopen = r));
ws.send(JSON.stringify({ type: "session", imvu_id: 42 }));
await waitFor(() => messages.some((m) => m.message === "not_linked"));
// An account linked while the client runs counts at once.
ws.send(JSON.stringify({ type: "session", imvu_id: 78 }));
await waitFor(() => messages.filter((m) => m.message === "not_linked").length === 2);
await link(erin, { id: 78, username: "ErinAlt", ageVerified: false, accessPass: false });
await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 78));
await erin.call("DELETE", "/api/v1/account/links/78");
await waitFor(() => !hub.realtime.online().some((o) => o.imvuId === 78));
ws.send(JSON.stringify({ type: "session", imvu_id: 77 }));
ws.send(JSON.stringify({ type: "outfit", items: [{ id: "blue-coat", name: "Blue Coat" }] }));
await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 77 && o.outfit.length === 1));
const visitor = new Browser();
expect((await visitor.call("GET", "/api/v1/online")).json.online).toHaveLength(0);
const admin = new Browser();
await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" });
const consoleView = (await admin.call("GET", "/api/v1/console")).json;
expect(consoleView.online[0]).toMatchObject({ username: "ErinAv", hidden: true });
expect(consoleView.counts.computers).toBe(1);
await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true });
const online = (await visitor.call("GET", "/api/v1/online")).json.online;
expect(online).toEqual([
{ imvuId: 77, username: "ErinAv", badge: true, since: expect.any(String), outfit: [{ id: "blue-coat", name: "Blue Coat", adult: false }] },
]);
// A ban closes the connection and ends the proxy's session.
const erinId = (await erin.call("GET", "/api/v1/me")).json.person.id;
const closed = new Promise((r) => (ws.onclose = r));
expect((await admin.call("POST", `/api/v1/console/people/${erinId}/ban`, { reason: "testing" })).status).toBe(200);
await closed;
expect((await fetch(`${base}/api/v1/me`, { headers: bearer })).status).toBe(401);
expect((await admin.call("GET", "/api/v1/console")).json.audit.map((e: any) => e.action)).toContain("person.ban");
}, 20_000);
});
async function waitFor(ok: () => boolean) {
for (let i = 0; i < 200; i++) {
if (ok()) return;
await Bun.sleep(10);
}
throw new Error("timed out");
}