import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { launchVerificationServer, runControlOmb, type VerificationServer } from "../scripts/control-omb.ts"; let fixture: VerificationServer; let botId: string; let clientToken: string; let ownerCookie: string; const remote = { "x-forwarded-for": "198.51.100.18", "x-forwarded-proto": "https" }; async function call(method: string, path: string, body?: unknown, headers: Record = {}) { const response = await fetch(`${fixture.info.url}${path}`, { method, headers: { "content-type": "application/json", ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(10_000), }); return { status: response.status, body: await response.json() as any, headers: response.headers }; } beforeAll(async () => { fixture = await launchVerificationServer(); const env = { OPENMAUSBOT_URL: fixture.info.url }; expect((await runControlOmb(["doctor"], { env }) as any).ok).toBe(true); const created = await runControlOmb(["new-bot", "--name", "Browser profile fixture"], { env }) as any; botId = created.bot.id; const clientPairing = await call("POST", "/api/auth/pairing", { scopes: ["client"] }); expect(clientPairing.status).toBe(200); const client = await call("POST", "/api/auth/pair", { code: clientPairing.body.code, label: "Browser client fixture" }, remote); expect(client.status).toBe(200); clientToken = client.body.token; const ownerPairing = await call("POST", "/api/auth/pairing", { scopes: ["admin", "client"] }); const owner = await call("POST", "/api/auth/pair", { code: ownerPairing.body.code, label: "Browser owner fixture", cookie: true }, remote); expect(owner.status).toBe(200); ownerCookie = owner.headers.get("set-cookie")!.split(";")[0]!; }, 30_000); afterAll(async () => { await fixture?.close(); }); describe("live browser route authorization", () => { it.each([["GET", "live"], ["POST", "action"]])("opens %s %s to the bot's people, never to strangers or another origin", async (method, suffix) => { const path = `/api/bots/${botId}/browser/${suffix}`; const body = method === "POST" ? { viewerId: "forged", type: "take" } : undefined; expect((await call(method, path, body, remote)).status).toBe(403); expect((await call("PATCH", "/api/config", { features: { browser: true } })).status).toBe(200); const client = await call(method, path, body, { ...remote, authorization: `Bearer ${clientToken}` }); expect(client.status).toBe(503); expect(client.body.error).toContain("Install the browser"); const crossOrigin = await call(method, path, body, { ...remote, cookie: ownerCookie, origin: "https://attacker.invalid" }); expect(crossOrigin.status).toBe(403); expect(crossOrigin.body.error).toContain("cross-origin"); // No native browser is installed or launched in this fixture; the built-in browser is on by default, so the first gate met is the missing engine. expect((await call("PATCH", "/api/config", { features: { browser: true } })).status).toBe(200); const onByDefault = await call(method, path, body, { ...remote, cookie: ownerCookie }); expect(onByDefault.status).toBe(503); expect(onByDefault.body.error).toContain("Install the browser"); expect((await call("PATCH", "/api/config", { features: { browser: false } })).status).toBe(200); const owner = await call(method, path, body, { ...remote, cookie: ownerCookie }); expect(owner.status).toBe(409); expect(owner.body.error).toContain("Enable this bot's browser"); }); it.each([["GET", "download/d1"], ["POST", "upload?chooser=c1"]])("opens the v2 %s %s to the bot's people only, as v1", async (method, suffix) => { const path = `/api/bots/${botId}/browser/${suffix}`; expect((await call("PATCH", "/api/config", { features: { browser: true } })).status).toBe(200); expect((await call(method, path, undefined, remote)).status).toBe(403); const client = await call(method, path, undefined, { ...remote, authorization: `Bearer ${clientToken}` }); expect(client.status).toBe(503); expect(client.body.error).toContain("Install the browser"); const crossOrigin = await call(method, path, undefined, { ...remote, cookie: ownerCookie, origin: "https://attacker.invalid" }); expect(crossOrigin.status).toBe(403); }); it("answers the v2 stream's handshake only for the bot's people, from its own origin", async () => { const stream = `${fixture.info.url.replace(/^http/, "ws")}/api/bots/${botId}/browser/stream`; const refused = (headers: Record) => new Promise((resolve) => { const socket = new WebSocket(stream, { headers } as unknown as string[]); socket.addEventListener("open", () => { resolve(0); socket.close(); }); socket.addEventListener("close", (event) => resolve(event.code)); }); expect(await refused(remote)).toBe(1006); expect(await refused({ ...remote, cookie: ownerCookie, origin: "https://attacker.invalid" })).toBe(1006); const plain = await fetch(stream.replace(/^ws/, "http"), { headers: { ...remote, authorization: `Bearer ${clientToken}` } }); expect(plain.status).toBe(426); }); it("does not let client sessions change the bot's browser identity or shared logins", async () => { const headers = { ...remote, authorization: `Bearer ${clientToken}` }; expect((await call("PATCH", `/api/bots/${botId}`, { browserProfile: "guest" }, headers)).status).toBe(403); expect((await call("PATCH", "/api/config", { browserProfiles: [], expectedBrowserProfiles: [] }, headers)).status).toBe(403); }); }); describe("profile list compare-and-swap", () => { it("rejects stale deletion without losing profiles, assignments, or a later rename", async () => { const original = [{ id: "work", name: "Work" }, { id: "personal", name: "Personal" }]; const created = await call("PATCH", "/api/config", { browserProfiles: original, expectedBrowserProfiles: [] }); expect(created.status).toBe(200); expect(created.body.browserProfiles).toEqual(original); expect((await call("PATCH", `/api/bots/${botId}`, { browserProfile: "work" })).status).toBe(200); const renamed = [{ id: "work", name: "Renamed work" }, original[1]]; const saved = await call("PATCH", "/api/config", { browserProfiles: renamed, expectedBrowserProfiles: original }); expect(saved.status).toBe(200); const stale = await call("PATCH", "/api/config", { browserProfiles: [], expectedBrowserProfiles: original }); expect(stale.status).toBe(409); expect(stale.body.error).toContain("another window"); expect((await call("GET", "/api/config")).body.browserProfiles).toEqual(renamed); const currentBot = (await call("GET", "/api/bots")).body.bots.find((bot: any) => bot.id === botId); expect(currentBot.browserProfile).toBe("work"); // The fixture has no engine or saved logins. const removed = await call("PATCH", "/api/config", { browserProfiles: [original[1]], expectedBrowserProfiles: renamed }); expect(removed.status).toBe(200); expect(removed.body.browserProfiles).toEqual([original[1]]); const afterRemoval = (await call("GET", "/api/bots")).body.bots.find((bot: any) => bot.id === botId); expect(afterRemoval.browserProfile).toBeUndefined(); }); });