Ctrl K
ComunidadGuías…

server/browser-sites.test.ts

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

Archivo original ↗Preguntar a KiBot
// No real engine, Chrome or user profile: the state file agent-browser saves, and a fake Chrome DevTools socket.
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { WebSocketServer } from "ws";

import { browserRestoreKey } from "./browser-engine.ts";
import { connectCdp, decryptBrowserState, encryptBrowserState, listSignedInSites, signOutOfSites, signedInSites, type BrowserSitesTarget, type CdpEvent } from "./browser-sites.ts";
import { removeTempDir } from "./testing/cleanup.ts";

const KEY = "a".repeat(64);
// Saved by agent-browser 0.37.0 itself (`--restore`, this key) after a page
// set cookies on .wikipedia.org, en.wikipedia.org and www.example.com and
// localStorage on https://example.com. It proves the at-rest format.
const SAVED_BY_ENGINE = Buffer.from([
  "v0VwcRCnddysYKlP8Vi4nfVk54WehcGyQcgqAQQq7KjjwF2lh1JU2URYi8akIgVEfbTzxULoojyvlIvCjcy4eoMt9SVqDzpRlk5j",
  "7z9IYJPB3mN9cuqsnSeq4VeGPYRbxrAiDJ0eRNw9q83hOIbntxq7O/IktkpuLh0UA2XEMwGn4ATvYRKzcSZbbWbVzd5+RiNjpLd4",
  "LQ1v9AvlgpJq1cp9d4wpYXjgSI2sbSiL64+IRy+/Pj8WjkfaUZgUBW74OT/F5eSv8QGJLmpSf/96lTbj08NK59rhcWVDjsG8fD7Y",
  "PLQPdzRdzgrcBFvLF5RJhw8B+5CNgYLQEViQhgt7C7Q2up+o1/9fT7/EyNmMs6f5dQI/JQId1nuKMaaU7YCKoUeQS9o3g+q5Clzg",
  "bD+RbwMlRbAlklnhF4NdGYbGI3KoAUp1H2uqbCVaUw4oEHmipmTJtRrfsmE19Y1M0e4ofvjkkUgsHWSVV46cpY7Gm1mtUPq9EfeF",
  "zUQNLbfOMRvaQuvIsfUss68w05u2/z6ARoRIxrTCN0f1K5QW8tBNEhgYkWZ2GpsJNmBDUsAvxtquZDezf++Gc1Uxil5NfKRE6OFB",
  "YeWo3dV3v8a+IOXRpdI0mkX6HZ1qu8orsMssOvB2zJtvl5gG+brsYeOHbkKhIbd8uWidrCJ9bn3IHKINU4H9QH1dRfPU9qvTOOCi",
  "HYNliv+MjDalXH/NHMPdqAS2pKVubuL2bj50MeJ+QAZcxvbivBuQLdJjdLYziPg1fJlcDA4KTNlD49HpP6eBXB79o4kdAxdse4aK",
  "xUY101QD3iSFulmEL8GkmfbSlRNcubBXlXnxQ3yU6WOAWBJtSYix7T8RCQw5/A0m7CsBeGWbKtHmUvIkS49ikXgZHnjnj3ahWqTv",
  "53XJgsMnVuGac3ZRPZXwXx8miWvpkw9p4mQze0TYk/D/ZYpgNxrWTzf5buJqnvnQ96h/5/zyRGkvnECdKlchBIUa7fNxFi6oCic8",
  "yxkoif+dOBvJvJx5RG4oHgiJhSNxaEdYcS2TFjnjFaVsp9Mv+bbCzZPM8nCaokW2vAO7oVD0sIWyH46Rr7vP6e8C4Z1O4l4NFMZy",
  "CvpTQWl5FmbWUW6AHhZcdQnM9sjT6voTVjQEW6vVVG4T1slwj3KtdiFxUWEMCJ1axQlDgo+kmXIjOk5d2fpVT4YqtoXOW2pxH/2A",
  "uA==",
].join(""), "base64");

const scratch: string[] = [];
const servers: WebSocketServer[] = [];
afterEach(async () => {
  await Promise.all(servers.splice(0).map((server) => new Promise((done) => server.close(done))));
  for (const dir of scratch.splice(0)) await removeTempDir(dir);
});

function profile(session = "work") {
  const home = mkdtempSync(join(tmpdir(), "omb-browser-sites-"));
  scratch.push(home);
  const directory = join(home, ".agent-browser", "sessions");
  mkdirSync(directory, { recursive: true });
  const current = join(directory, `${browserRestoreKey(session)}-${session}.json.enc`);
  const target: BrowserSitesTarget = { binaryPath: "/fake/agent-browser", session, env: { HOME: home, USERPROFILE: home }, encryptionKey: KEY };
  const saved = (path = current) => JSON.parse(decryptBrowserState(readFileSync(path), KEY));
  return { home, directory, current, target, saved };
}

const stopped = { engine: async () => null };

describe("a profile's signed-in sites, from its saved session", () => {
  it("lists each site with saved cookies or storage, read from the file agent-browser saved", async () => {
    const { current, target } = profile();
    writeFileSync(current, SAVED_BY_ENGINE);
    expect(await listSignedInSites(target, stopped)).toEqual({
      running: false,
      sites: [
        { site: "example.com", cookies: 1, storage: 1 },
        { site: "wikipedia.org", cookies: 2, storage: 0 },
      ],
    });
  });

  it("reads the spelling a profile used before its restore key while the current one is missing", async () => {
    const { directory, target } = profile();
    writeFileSync(join(directory, "work-work.json.enc"), SAVED_BY_ENGINE);
    expect((await listSignedInSites(target, stopped)).sites.map((site) => site.site)).toEqual(["example.com", "wikipedia.org"]);
  });

  it("groups hosts under the widest one the browser stores for, never under a public suffix", () => {
    const cookie = (domain: string) => ({ name: "sid", value: "1", domain, path: "/" });
    expect(signedInSites(
      [cookie(".google.com"), cookie("accounts.google.com"), cookie("mail.google.com"), cookie("www.bbc.co.uk"), cookie("app.example.org"), cookie("127.0.0.1")],
      [{ origin: "https://docs.google.com", localStorage: [{ name: "a", value: "b" }] }, { origin: "https://empty.example.org", localStorage: [] }],
    )).toEqual([
      { site: "127.0.0.1", cookies: 1, storage: 0 },
      { site: "app.example.org", cookies: 1, storage: 0 },
      { site: "google.com", cookies: 3, storage: 1 },
      { site: "www.bbc.co.uk", cookies: 1, storage: 0 },
    ]);
  });

  it("an unreadable file restores nothing, so it lists nothing", async () => {
    const { current, target } = profile();
    writeFileSync(current, encryptBrowserState(JSON.stringify({ cookies: [{ name: "a", value: "b", domain: "x.test", path: "/" }], origins: [] }), "b".repeat(64)));
    expect(await listSignedInSites(target, stopped)).toEqual({ running: false, sites: [] });
  });
});

describe("signing a stopped browser out", () => {
  it("removes one site's cookies and storage and leaves every other site", async () => {
    const { current, directory, target, saved } = profile();
    writeFileSync(current, SAVED_BY_ENGINE);
    writeFileSync(join(directory, "work-work.json.enc"), SAVED_BY_ENGINE);
    writeFileSync(`${current}.previous`, SAVED_BY_ENGINE);
    const result = await signOutOfSites(target, "wikipedia.org", stopped);
    expect(result).toEqual({ running: false, sites: [{ site: "example.com", cookies: 1, storage: 1 }] });
    for (const path of [current, join(directory, "work-work.json.enc")]) {
      const state = saved(path);
      expect(state.cookies.map((cookie: { domain: string }) => cookie.domain)).toEqual(["www.example.com"]);
      expect(state.origins.map((origin: { origin: string }) => origin.origin)).toEqual(["https://example.com"]);
      expect(state.cookies[0]).toMatchObject({ name: "sid", value: "s1", path: "/", httpOnly: true, secure: true });
    }
    expect(existsSync(`${current}.previous`)).toBe(false);
  });

  it("signs out of every site in every saved copy", async () => {
    const { current, directory, target, saved } = profile();
    writeFileSync(current, SAVED_BY_ENGINE);
    writeFileSync(join(directory, "work-work.json"), JSON.stringify({ cookies: [{ name: "old", value: "1", domain: "old.test", path: "/" }], origins: [] }));
    expect(await signOutOfSites(target, null, stopped)).toEqual({ running: false, sites: [] });
    expect(saved()).toEqual({ cookies: [], origins: [] });
    expect(JSON.parse(readFileSync(join(directory, "work-work.json"), "utf8"))).toEqual({ cookies: [], origins: [] });
  });

  it("changes nothing for a site that is not listed, such as a whole top-level domain", async () => {
    const { current, target } = profile();
    writeFileSync(current, SAVED_BY_ENGINE);
    for (const site of ["org", "com", "nobody.test"]) {
      expect((await signOutOfSites(target, site, stopped)).sites).toHaveLength(2);
      expect(readFileSync(current).equals(SAVED_BY_ENGINE)).toBe(true);
    }
  });

  it("leaves other profiles' saved sessions alone", async () => {
    const { directory, current, target } = profile();
    const other = join(directory, `${browserRestoreKey("work-client")}-work-client.json.enc`);
    writeFileSync(current, SAVED_BY_ENGINE);
    writeFileSync(other, SAVED_BY_ENGINE);
    await signOutOfSites(target, null, stopped);
    expect(readFileSync(other).equals(SAVED_BY_ENGINE)).toBe(true);
  });
});

/** A fake running browser: the engine's `state save` and `get cdp-url`, and
 * a Chrome DevTools endpoint over one cookie jar and per-origin storage. */
async function fakeChrome(cookies: Array<Record<string, unknown>>, storage: Record<string, string> = {}, pages = [{ type: "page", targetId: "page-1", url: "https://en.wikipedia.org/wiki/Main_Page" }, { type: "page", targetId: "page-2", url: "https://www.example.com/" }]) {
  const jar = [...cookies];
  const stored = new Map(Object.entries(storage));
  const calls: Array<{ method: string; params: Record<string, unknown>; sessionId?: string }> = [];
  const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
  servers.push(server);
  server.on("connection", (socket) => socket.on("message", (data) => {
    const { id, method, params = {}, sessionId } = JSON.parse(String(data));
    calls.push({ method, params, sessionId });
    let result: Record<string, unknown> = {};
    if (method === "Target.getTargets") result = { targetInfos: [...pages, { type: "service_worker", targetId: "sw" }] };
    if (method === "Target.attachToTarget") result = { sessionId: `on-${params.targetId}` };
    if (method === "Network.getAllCookies") result = { cookies: jar };
    if (method === "Network.deleteCookies") {
      const index = jar.findIndex((cookie) => cookie.name === params.name && cookie.domain === params.domain && cookie.path === params.path);
      if (index >= 0) jar.splice(index, 1);
    }
    if (method === "Network.clearBrowserCookies") jar.length = 0;
    if (method === "Storage.clearDataForOrigin") stored.delete(String(params.origin));
    socket.send(JSON.stringify({ id, result, ...(sessionId ? { sessionId } : {}) }));
  }));
  await new Promise((ready) => server.on("listening", ready));
  const url = `ws://127.0.0.1:${(server.address() as { port: number }).port}/devtools/browser/fake`;
  const commands: string[][] = [];
  const engine = async (_target: BrowserSitesTarget, args: string[]) => {
    commands.push(args);
    if (args[0] === "get" && args[1] === "cdp-url") return { cdpUrl: url };
    if (args[0] === "state" && args[1] === "save") {
      const origins = [...stored].map(([origin, value]) => ({ origin, localStorage: [{ name: "token", value }], sessionStorage: [] }));
      writeFileSync(`${args[2]}.enc`, encryptBrowserState(JSON.stringify({ cookies: jar, origins }), KEY));
      return { path: `${args[2]}.enc`, saved: true };
    }
    throw new Error(`unexpected engine command ${args.join(" ")}`);
  };
  return { jar, stored, calls, commands, engine };
}

const cookie = (name: string, domain: string) => ({ name, value: "v", domain, path: "/", httpOnly: true, secure: true });

describe("signing a running browser out", () => {
  it("lists what the running browser holds now, as the engine saves it", async () => {
    const { current, target } = profile();
    writeFileSync(current, SAVED_BY_ENGINE);
    const chrome = await fakeChrome([cookie("SID", ".google.com"), cookie("sid", "www.example.com")], { "https://mail.google.com": "t" });
    expect(await listSignedInSites(target, { engine: chrome.engine })).toEqual({
      running: true,
      sites: [{ site: "google.com", cookies: 1, storage: 1 }, { site: "www.example.com", cookies: 1, storage: 0 }],
    });
    expect(chrome.calls).toEqual([]);
  });

  it("deletes one site's cookies live, clears its storage and tabs, rewrites the saved file, and leaves the rest", async () => {
    const { current, target, saved } = profile();
    writeFileSync(current, SAVED_BY_ENGINE);
    const chrome = await fakeChrome(
      [cookie("sid", ".wikipedia.org"), cookie("sid", "en.wikipedia.org"), cookie("sid", "www.example.com"), cookie("SID", ".google.com")],
      { "https://en.wikipedia.org": "w", "https://example.com": "e" },
    );
    const result = await signOutOfSites(target, "wikipedia.org", { engine: chrome.engine });
    expect(result).toEqual({ running: true, sites: [{ site: "example.com", cookies: 1, storage: 1 }, { site: "google.com", cookies: 1, storage: 0 }] });
    expect(chrome.jar.map((entry) => entry.domain)).toEqual(["www.example.com", ".google.com"]);
    expect(chrome.calls.filter((call) => call.method === "Network.deleteCookies").map((call) => call.params))
      .toEqual([{ name: "sid", domain: ".wikipedia.org", path: "/" }, { name: "sid", domain: "en.wikipedia.org", path: "/" }]);
    expect(chrome.calls.some((call) => call.method === "Network.clearBrowserCookies")).toBe(false);
    const cleared = chrome.calls.filter((call) => call.method === "Storage.clearDataForOrigin").map((call) => String(call.params.origin));
    expect(cleared).toEqual(expect.arrayContaining(["https://wikipedia.org", "https://en.wikipedia.org"]));
    expect(cleared.some((origin) => origin.includes("example.com"))).toBe(false);
    expect([...chrome.stored.keys()]).toEqual(["https://example.com"]);
    expect(chrome.calls.filter((call) => call.method === "Runtime.evaluate").map((call) => call.sessionId)).toEqual(["on-page-1"]);
    expect(saved().cookies.map((entry: { domain: string }) => entry.domain)).toEqual(["www.example.com"]);
  });

  it("signs out of all: clears every cookie and origin live and empties the saved file", async () => {
    const { current, target, saved } = profile();
    writeFileSync(current, SAVED_BY_ENGINE);
    const chrome = await fakeChrome([cookie("sid", ".wikipedia.org"), cookie("sid", "www.example.com")], { "https://example.com": "e" });
    expect(await signOutOfSites(target, null, { engine: chrome.engine })).toEqual({ running: true, sites: [] });
    expect(chrome.calls.filter((call) => call.method === "Network.clearBrowserCookies")).toHaveLength(1);
    expect(chrome.calls.filter((call) => call.method === "Storage.clearDataForOrigin").map((call) => call.params.origin))
      .toEqual(expect.arrayContaining(["https://wikipedia.org", "https://www.example.com", "https://example.com"]));
    expect(saved()).toEqual({ cookies: [], origins: [] });
  });

  it("starts a stopped browser with a kept profile to sign it out there too, then stops it again", async () => {
    const { home, current, target, saved } = profile();
    writeFileSync(current, SAVED_BY_ENGINE);
    const kept = join(home, "agent-browser-profiles", "work");
    mkdirSync(kept, { recursive: true });
    const chrome = await fakeChrome([cookie("sid", ".wikipedia.org"), cookie("sid", "www.example.com")]);
    let running = false;
    const lifecycle: string[] = [];
    const deps = {
      engine: async (given: BrowserSitesTarget, args: string[]) => running ? chrome.engine(given, args) : null,
      start: async () => { lifecycle.push("start"); running = true; return true; },
      stop: async () => { lifecycle.push("stop"); running = false; return true; },
    };
    const withProfile = { ...target, env: { ...target.env, AGENT_BROWSER_PROFILE: kept } };
    expect(await listSignedInSites(withProfile, deps)).toMatchObject({ running: false });
    expect(lifecycle).toEqual([]);
    expect(await signOutOfSites(withProfile, "wikipedia.org", deps)).toEqual({ running: false, sites: [{ site: "www.example.com", cookies: 1, storage: 0 }] });
    expect(lifecycle).toEqual(["start", "stop"]);
    expect(chrome.jar.map((entry) => entry.domain)).toEqual(["www.example.com"]);
    expect(saved().cookies.map((entry: { domain: string }) => entry.domain)).toEqual(["www.example.com"]);
    await signOutOfSites(target, "example.com", deps);
    expect(lifecycle).toEqual(["start", "stop"]);
  });

  it("only ever connects to the engine's own Chrome on this machine", async () => {
    const { target } = profile();
    const chrome = await fakeChrome([cookie("sid", "www.example.com")]);
    const engine = async (_target: BrowserSitesTarget, args: string[]) =>
      args[0] === "get" ? { cdpUrl: "ws://browser.example.net:9222/devtools/browser/x" } : chrome.engine(_target, args);
    await expect(signOutOfSites(target, null, { engine })).rejects.toThrow("DevTools address");
    expect(chrome.calls).toEqual([]);
  });
});

describe("the DevTools connection", () => {
  it("hands events to its listener, answers to their callers, and says when the browser closes it", async () => {
    const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
    servers.push(server);
    server.on("connection", (socket) => socket.on("message", (data) => {
      const { id, sessionId } = JSON.parse(String(data));
      socket.send(JSON.stringify({ method: "Target.attachedToTarget", params: { sessionId: "child", targetInfo: { type: "iframe" } }, sessionId }));
      socket.send(JSON.stringify({ id, result: { ok: true } }));
      setTimeout(() => socket.close(), 10);
    }));
    await new Promise((ready) => server.on("listening", ready));
    const events: CdpEvent[] = [];
    let closed!: () => void;
    const gone = new Promise<void>((resolve) => { closed = resolve; });
    const cdp = await connectCdp(`ws://127.0.0.1:${(server.address() as { port: number }).port}/devtools/browser/x`, 2_000, { event: (event) => events.push(event), closed });
    await expect(cdp.send("Target.setAutoAttach", {}, "page")).resolves.toEqual({ ok: true });
    await gone;
    expect(events).toEqual([{ method: "Target.attachedToTarget", params: { sessionId: "child", targetInfo: { type: "iframe" } }, sessionId: "page" }]);
    await expect(cdp.send("Browser.getVersion")).rejects.toThrow();
  });
});