CI / test (push) Successful in 30s
The hub becomes a Bun monorepo: apps/api (Elysia, Better Auth for accounts, roles, bans, proxy device sign-in and JWT keys; tagline linking, presence and the console as before), packages/db (Drizzle; Postgres in production, PGlite in development and tests), and the Next.js website from apps/web. Self-hostable with docker compose; the website deploys to Vercel-style hosts, the API to any that runs Bun or containers. The first version, in Go, is removed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
250 lines
11 KiB
TypeScript
250 lines
11 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"));
|
|
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");
|
|
}
|