// API tokens (docs/console.md): made from a session, limited to their grants and to what their owner holds now, // usable only on routes that check permissions, audited by name, revocable. 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 apiURL = "http://api.test"; const accounts = new Map(); const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => { const url = new URL(String(input)); if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 }); 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; // Catalog addresses that answer /health (for address changes). const healthy = new Set(["https://moved.example.test"]); let db: Database; let close: () => Promise; let base: string; let stop: () => void; beforeAll(async () => { ({ db, close } = await openDatabase("memory")); const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false }); const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU), apiURL, webURL, catalogHealthy: async (url) => healthy.has(url), }); 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; name: string }; async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record = {}) { const res = await fetch(base + path, { method, headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers }, body: body !== undefined ? JSON.stringify(body) : undefined, }); const text = await res.text(); let json: any; try { json = JSON.parse(text); } catch { json = text; } return { status: res.status, json }; } async function person(name: string): 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 }), }); return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name }; } const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n }); /** A person's catalog, registered and approved; its key's Authorization header. */ async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") { const reg = await call(owner, "POST", "/api/v1/catalogs", { name, url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`, contact: "takedowns@example.test", visibility: "public", membership, }); expect(reg.status).toBe(201); const id = reg.json.catalog.id as string; expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200); return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } }; } /** A product uploaded by a person to a catalog; its ID. */ async function upload(maker: Person, c: { id: string; auth: Record }, name: string, n: number, rating = "general") { const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id }); expect(tok.status).toBe(200); const r = await call( undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" }, c.auth, ); expect(r.status).toBe(201); expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200); return r.json.product as string; } /** A revision with these files, by a person, to a catalog: the answer. */ async function uploadFiles(maker: Person, c: { id: string; auth: Record }, name: string, files: unknown[]) { const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id }); return call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: tok.json.token, files, rating: "general", parent: 0, name, notes: "" }, c.auth); } let admin: Person; let mod: Person; test("the first account is the admin; a moderator", async () => { admin = await person("admin1"); mod = await person("warden"); const id = (await call(mod, "GET", "/api/v1/me")).json.person.id; expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "moderator" })).status).toBe(200); }); const bearer = (token: string) => ({ Authorization: `Bearer ${token}` }); const at = (type: "product" | "catalog", id: string) => ({ type, id }); test("tokens: made from a session, limited to their grants, usable only where permissions are checked", async () => { const owner = await person("shelfowner"); const maker = await person("maker"); const c = await catalogOf(owner, admin, "Token Shelf"); const hat = await upload(maker, c, "Hat", 1); const scarf = await upload(maker, c, "Scarf", 2); const theirs = await upload(owner, c, "Owner's Coat", 3); // Only what the person holds now. const make = (p: Person, grants: unknown[], name = "script", days = 30) => call(p, "POST", "/api/v1/account/tokens", { name, days, grants }); expect((await make(maker, [{ permission: "product:edit", scope: at("product", theirs) }])).json.error).toBe("not_held"); expect((await make(maker, [{ permission: "product:remove", scope: { type: "hub" } }])).json.error).toBe("not_held"); expect((await make(maker, [{ permission: "no:such", scope: at("product", hat) }])).status).toBe(400); expect((await make(maker, [])).status).toBe(400); expect((await make(maker, [{ permission: "product:edit", scope: at("product", hat) }], "x", 400)).status).toBe(400); expect((await make(undefined as unknown as Person, [{ permission: "product:edit", scope: at("product", hat) }])).status).toBe(401); const made = await make(maker, [ { permission: "product:edit", scope: at("product", hat) }, { permission: "revision:upload", scope: at("product", hat) }, { permission: "product:edit", scope: at("product", hat) }, ], "Hat updater"); expect(made.status).toBe(201); const tok = made.json.token as string; expect(tok).toMatch(/^t5de_pat_[A-Za-z0-9_-]{43}$/); expect(made.json.info).toMatchObject({ name: "Hat updater", status: "active", lastUsedAt: null }); expect(made.json.info.grants).toEqual([ { permission: "product:edit", scope: at("product", hat), name: "Hat" }, { permission: "revision:upload", scope: at("product", hat), name: "Hat" }, ]); expect(JSON.stringify(made.json.info)).not.toContain(tok); // What it grants, there. const asToken = (method: string, path: string, body?: unknown) => call(undefined, method, path, body, bearer(tok)); expect((await asToken("PATCH", `/api/v1/products/${hat}`, { description: "By script" })).status).toBe(200); const other = await asToken("PATCH", `/api/v1/products/${scarf}`, { description: "Nope" }); expect(other.status).toBe(403); expect(other.json.missing).toEqual({ permission: "product:edit", scope: { product: scarf } }); expect((await asToken("POST", `/api/v1/products/${hat}/publish`)).status).toBe(403); expect((await asToken("POST", "/api/v1/products/upload", { catalog: c.id, product: hat })).status).toBe(200); expect((await asToken("POST", "/api/v1/products/upload", { catalog: c.id, product: scarf })).status).toBe(403); expect((await asToken("POST", "/api/v1/products/upload", { catalog: c.id })).json.error).toBe("token_not_allowed"); // Nothing else: session-only routes refuse it, and it can't make tokens; elsewhere it's signed out. expect((await asToken("GET", "/api/v1/me")).json.error).toBe("token_not_allowed"); expect((await asToken("POST", "/api/v1/account/tokens", { name: "more", days: 1, grants: [{ permission: "product:edit", scope: at("product", hat) }] })).json.error).toBe("token_not_allowed"); expect((await asToken("DELETE", `/api/v1/products/${hat}`)).json.error).toBe("token_not_allowed"); expect((await asToken("POST", "/api/v1/cases", { kind: "report", about: { product: theirs }, reason: "x" })).json.error).toBe("token_not_allowed"); // Audited as the person, by token; listed with its last use. const entry = (await call(maker, "GET", `/api/v1/console/audit?action=product.edit&product=${hat}`)).json.entries[0]; expect(entry).toMatchObject({ actor: "maker", detail: { token: "Hat updater" } }); const listed = (await call(maker, "GET", "/api/v1/account/tokens")).json.tokens; expect(listed).toHaveLength(1); expect(listed[0].lastUsedAt).not.toBeNull(); expect((await call(owner, "GET", "/api/v1/account/tokens")).json.tokens).toEqual([]); // Revoked or expired: refused. expect((await call(owner, "DELETE", `/api/v1/account/tokens/${listed[0].id}`)).status).toBe(404); expect((await call(maker, "DELETE", `/api/v1/account/tokens/${listed[0].id}`)).status).toBe(204); expect((await asToken("PATCH", `/api/v1/products/${hat}`, { description: "Again" })).json.error).toBe("bad_token"); expect((await call(maker, "GET", "/api/v1/account/tokens")).json.tokens[0].status).toBe("revoked"); expect((await call(undefined, "PATCH", `/api/v1/products/${hat}`, { description: "x" }, bearer("t5de_pat_nope"))).json.error).toBe("bad_token"); const short = (await make(maker, [{ permission: "product:edit", scope: at("product", hat) }], "short", 1)).json; await db.update(schema.apiToken).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(schema.apiToken.id, short.info.id)); expect((await call(undefined, "PATCH", `/api/v1/products/${hat}`, { description: "x" }, bearer(short.token))).json.error).toBe("bad_token"); }); test("tokens never have more than their owner holds now", async () => { const made = await call(mod, "POST", "/api/v1/account/tokens", { name: "blocks", days: 7, grants: [{ permission: "product:remove", scope: { type: "hub" } }] }); expect(made.status).toBe(201); const tok = made.json.token as string; expect((await call(undefined, "GET", "/api/v1/blocks", undefined, bearer(tok))).status).toBe(200); // Its grants don't reach other permissions the moderator holds. expect((await call(undefined, "GET", `/api/v1/catalogs/${(await call(mod, "GET", "/api/v1/console/catalogs")).json.catalogs[0].id}/team`, undefined, bearer(tok))).status).toBe(403); const id = (await call(mod, "GET", "/api/v1/me")).json.person.id; expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "user" })).status).toBe(200); expect((await call(undefined, "GET", "/api/v1/blocks", undefined, bearer(tok))).status).toBe(403); });