Ctrl K
ComunidadGuías…

server/browser-live-routes.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string, string> = {}) {
  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<string, string>) => new Promise<number>((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();
  });
});