// 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(); // 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; let hub: ReturnType; 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 = {}) { 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) { 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: "other@example.test", 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")); 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" }] }, ]); // 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"); }