Ctrl K
ComunidadGuías…

server/admin-activity.e2e.test.ts

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

Archivo original ↗Preguntar a KiBot
import { spawn, type ChildProcess } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";

import { SessionRegistry } from "./sessions.ts";
import { removeTempDir, waitForExit } from "./testing/cleanup.ts";
import { workerPort } from "./testing/worker-port.ts";

const SERVER_DIR = dirname(fileURLToPath(import.meta.url));
const PORT = workerPort(28800, 10_000);
const BASE = `http://127.0.0.1:${PORT}`;
const posixOnly = describe.skipIf(process.platform === "win32");
const BOSS = "boss@example.test";
const ADA = "ada@example.test";
const SECRET = "sk-ant-api03-activity-fixture-secret-0123456789abcdef";
const MCP_SECRET = "mcp-activity-fixture-secret-value";
const ARG_SECRET = "acme_live_9f8e7d6c5b4a3f2e1d0c";

let child: ChildProcess;
let home: string;
let data: string;
let log = "";
const tokens: Record<string, string> = {};
let oldPhone = "";

const api = async (method: string, path: string, body?: unknown, as?: string, extra: Record<string, string> = {}): Promise<{ status: number; body: any; text: string; headers: Headers }> => {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: { ...(body !== undefined ? { "content-type": "application/json" } : {}), ...(as ? { authorization: `Bearer ${tokens[as]}` } : {}), ...extra },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const text = await res.text();
  let parsed: any = {};
  try {
    parsed = JSON.parse(text);
  } catch {
  }
  return { status: res.status, body: parsed, text, headers: res.headers };
};

async function start() {
  child = spawn(process.execPath, [join(SERVER_DIR, "index.ts")], {
    cwd: join(SERVER_DIR, ".."),
    env: {
      ...(process.env.PATH ? { PATH: process.env.PATH } : {}),
      ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
      HOME: home, USERPROFILE: home, OMB_PORT: String(PORT), OMB_WEBHOOK_PORT: String(PORT + 1),
    },
    stdio: ["ignore", "pipe", "pipe"],
  });
  child.stdout!.on("data", (c) => (log += c));
  child.stderr!.on("data", (c) => (log += c));
  const deadline = Date.now() + 20_000;
  for (;;) {
    try {
      if ((await fetch(`${BASE}/api/health`)).ok) return;
    } catch {
    }
    if (Date.now() > deadline) throw new Error(`server never came up:\n${log}`);
    await new Promise((r) => setTimeout(r, 150));
  }
}

/** A request whose body is held back until `release()`: the server has
 * started on it (and taken its "before") while other requests run. */
function slowRequest(method: string, path: string, body: unknown, as?: string) {
  const text = new TextEncoder().encode(JSON.stringify(body));
  let release!: () => void;
  const gate = new Promise<void>((resolve) => (release = resolve));
  const stream = new ReadableStream<Uint8Array>({
    async start(controller) {
      controller.enqueue(text.slice(0, 2));
      await gate;
      controller.enqueue(text.slice(2));
      controller.close();
    },
  });
  const response = fetch(`${BASE}${path}`, {
    method,
    headers: { "content-type": "application/json", ...(as ? { authorization: `Bearer ${tokens[as]}` } : {}) },
    body: stream,
    duplex: "half",
  } as RequestInit);
  return { release, response };
}

async function activity(query = "", as = BOSS): Promise<any[]> {
  // Rows are written once the change's response has gone out.
  await new Promise((r) => setTimeout(r, 150));
  const res = await api("GET", `/api/admin-activity${query}`, undefined, as);
  expect(res.status, JSON.stringify(res.body)).toBe(200);
  return res.body.entries;
}
const month = () => new Date().toISOString().slice(0, 7);

posixOnly("admin activity log", () => {
  beforeAll(async () => {
    home = mkdtempSync(join(tmpdir(), "omb-admin-activity-"));
    data = join(home, ".openmausbot");
    mkdirSync(join(data, "decisions"), { recursive: true });
    writeFileSync(join(data, "config.json"), JSON.stringify({ signIn: { admins: [BOSS], members: [ADA] } }));
    // One card Ada answered earlier, as the decision log recorded it.
    writeFileSync(join(data, "decisions", `${month()}.ndjson`), [
      { at: new Date(Date.now() - 60_000).toISOString(), threadId: "t1", requestId: "r1", botName: "Ops", tool: "Bash", summary: "echo hi",
        decision: "user-approved", source: "user", actor: { kind: "session", sessionId: "s-ada", label: "ada's laptop", email: ADA } },
      { at: new Date(Date.now() - 50_000).toISOString(), threadId: "t1", requestId: "r2", tool: "Read", decision: "auto-approved", source: "full-access" },
    ].map((row) => JSON.stringify(row)).join("\n") + "\n");
    const role = (email: string) => (email === BOSS ? ["admin", "client"] as const : ["client"] as const);
    const registry = new SessionRegistry({ file: join(data, "sessions.json"), emailScopes: (email) => [...role(email)] });
    for (const email of [BOSS, ADA]) tokens[email] = registry.issue({ label: `${email.split("@")[0]}'s laptop`, email, scopes: [...role(email)] }).token;
    oldPhone = registry.issue({ label: "Old phone", scopes: ["client"] }).session.id;
    registry.close();
    await start();
  }, 40_000);

  afterAll(async () => {
    await waitForExit(child, { signal: "SIGTERM" });
    await removeTempDir(home);
  });

  it("is an admin's to read", async () => {
    expect((await api("GET", "/api/admin-activity", undefined, ADA)).status).toBe(403);
    expect((await api("GET", "/api/admin-activity.csv", undefined, ADA)).status).toBe(403);
    expect((await api("GET", "/api/admin-activity?what=everything", undefined, BOSS)).status).toBe(400);
    expect((await api("GET", "/api/admin-activity?from=2026-02-31", undefined, BOSS)).status).toBe(400);
  });

  it("records settings, people, keys and budgets with who changed them, never a secret", async () => {
    expect((await api("PUT", "/api/config", { signIn: { admins: [BOSS], members: [ADA, "carol@example.test"] } }, BOSS)).status).toBe(200);
    expect((await api("PUT", "/api/config", { anthropic: { key: SECRET } }, BOSS)).status).toBe(200);
    expect((await api("PUT", "/api/config", { budgets: { monthlyUsd: 75 } }, BOSS)).status).toBe(200);
    expect((await api("PUT", "/api/config", { decisions: { retentionDays: 400 } })).status).toBe(200); // the owner, on this machine
    expect((await api("PUT", "/api/config", { profile: { name: "Ops desk" } }, undefined, { "x-openmausbot-cli": "1" })).status).toBe(200);

    const people = (await activity("?what=people"))[0];
    expect(people).toMatchObject({ type: "admin", who: BOSS, what: "people", action: "people.update", changed: ["signIn.members"],
      before: { "signIn.members": [ADA] }, after: { "signIn.members": [ADA, "carol@example.test"] } });
    const engine = (await activity("?what=engine"))[0];
    expect(engine).toMatchObject({ who: BOSS, action: "engine.update", changed: ["anthropic.key"], after: { "anthropic.key": "[hidden]" } });
    const budget = (await activity("?what=budget"))[0];
    expect(budget).toMatchObject({ who: BOSS, changed: ["budgets.monthlyUsd"], after: { "budgets.monthlyUsd": 75 } });
    const config = await activity("?what=config");
    expect(config[0]).toMatchObject({ who: "Command line", changed: ["profile.name"], after: { "profile.name": "Ops desk" } });
    expect(config[1]).toMatchObject({ who: "This computer", changed: ["decisions.retentionDays"], after: { "decisions.retentionDays": 400 } });

    const dir = join(data, "admin-activity");
    for (const name of readdirSync(dir)) {
      expect(readFileSync(join(dir, name), "utf8")).not.toContain("activity-fixture-secret");
      expect(statSync(join(dir, name)).mode & 0o777).toBe(0o600);
    }
  });

  it("records MCP servers, webhooks, bots, a bot's audience and sessions", async () => {
    const mcp = await api("POST", "/api/mcp/servers", { name: "fixture", command: process.execPath, args: ["--version", "--api-key", ARG_SECRET], env: { FIXTURE_TOKEN: MCP_SECRET } }, BOSS);
    expect(mcp.status, JSON.stringify(mcp.body)).toBe(201);
    const bot = await api("POST", "/api/bots", { name: "Audit Owl" }, BOSS);
    expect(bot.status).toBe(201);
    const botId = bot.body.bot.id as string;
    expect((await api("PATCH", `/api/bots/${botId}`, { visibility: { people: [ADA] } }, BOSS)).status).toBe(200);
    expect((await api("PATCH", `/api/bots/${botId}`, { parkDirectMessages: true }, BOSS)).status).toBe(200);
    expect((await api("PATCH", `/api/bots/${botId}`, { pinned: true }, ADA)).status).toBe(200);
    expect((await api("PATCH", `/api/bots/${botId}`, { visibility: { people: ["nope"] } }, BOSS)).status).toBe(400);
    const signOut = await api("POST", "/api/instances/no-such-engine/auth/sign-out", {}, BOSS);
    expect(signOut.status, JSON.stringify(signOut.body)).toBeGreaterThanOrEqual(400);
    const hook = await api("POST", "/api/webhooks", { name: "Audit feed", botId, prompt: "Read it.", enabled: false }, BOSS);
    expect(hook.status).toBe(201);
    expect((await api("DELETE", `/api/webhooks/${hook.body.webhook.id}`, undefined, BOSS)).status).toBe(200);
    expect((await api("DELETE", `/api/auth/sessions/${oldPhone}`, undefined, BOSS)).status).toBe(200);
    expect((await api("POST", "/api/auth/pairing", { label: "Reception iPad", scopes: ["client"] }, BOSS)).status).toBe(200);
    expect((await api("DELETE", `/api/bots/${botId}`, undefined, BOSS)).status).toBe(200);

    const rows = (await activity("?what=all")).filter((entry) => entry.type === "admin");
    const actions = rows.map((entry) => entry.action);
    for (const action of ["mcp.update", "bot.create", "visibility.update", "bot.update", "webhook.create", "webhook.delete", "session.revoke", "pairing.create", "bot.delete"]) {
      expect(actions, action).toContain(action);
    }
    expect(actions.filter((action) => action === "bot.update")).toHaveLength(1);
    expect(actions).not.toContain("engine.auth-sign-out");
    expect(actions.filter((action) => action === "visibility.update")).toHaveLength(1);
    const audience = rows.find((entry) => entry.action === "visibility.update");
    expect(audience).toMatchObject({ who: BOSS, target: { kind: "bot", id: botId, name: "Audit Owl" }, before: { visibility: "everyone" }, after: { visibility: { people: [ADA] } } });
    const mcpRow = rows.find((entry) => entry.action === "mcp.update");
    expect(JSON.stringify(mcpRow)).not.toContain(MCP_SECRET);
    expect(JSON.stringify(mcpRow)).not.toContain(ARG_SECRET);
    expect(JSON.stringify(mcpRow)).toContain("--api-key");
    expect(JSON.stringify(mcpRow)).toContain("FIXTURE_TOKEN");
    expect(rows.find((entry) => entry.action === "session.revoke")).toMatchObject({ target: { id: oldPhone, name: "Old phone" } });
    expect(rows.find((entry) => entry.action === "pairing.create")).toMatchObject({ after: { label: "Reception iPad", scopes: ["client"] } });
    expect(rows.find((entry) => entry.action === "bot.delete")).toMatchObject({ target: { id: botId }, before: { visibility: { people: [ADA] } } });
  });

  it("shows answered cards with admin actions, filtered by who, what and when, and exports them", async () => {
    const all = await activity();
    const approval = all.find((entry) => entry.type === "approval");
    expect(approval).toMatchObject({ who: ADA, what: "user-approved", bot: "Ops", tool: "Bash" });
    expect(all.some((entry) => entry.what === "auto-approved")).toBe(false);
    expect((await activity("?what=decisions")).map((entry) => entry.what).sort()).toEqual(["auto-approved", "user-approved"]);
    expect((await activity("?what=approvals")).map((entry) => entry.who)).toEqual([ADA]);
    expect((await activity(`?who=${encodeURIComponent("ADA@")}`)).map((entry) => entry.type)).toEqual(["approval"]);
    expect((await activity("?who=command")).map((entry) => entry.action)).toEqual(["config.update"]);
    const times = all.map((entry) => entry.at);
    expect([...times].sort().reverse()).toEqual(times);
    expect(await activity("?from=2020-01-01&to=2020-01-31")).toEqual([]);

    const csv = await api("GET", "/api/admin-activity.csv?what=all", undefined, BOSS);
    expect(csv.status).toBe(200);
    expect(csv.headers.get("content-type")).toMatch(/text\/csv/);
    expect(csv.text.split("\n")[0]).toBe("time,type,who,what,action,target,changed,before,after,bot,tool,summary,thread");
    expect(csv.text).toContain("visibility.update");
    expect(csv.text).not.toContain("activity-fixture-secret");
    expect(csv.text).not.toContain(ARG_SECRET);
  });

  it("puts each concurrent change down to the admin who made it", async () => {
    const bot = (await api("POST", "/api/bots", { name: "Race Finch" }, BOSS)).body.bot as { id: string };
    // Boss's request is slow: its body arrives only after the owner on this
    // machine has made (and finished) a change of their own to the same thing.
    const slowBot = slowRequest("PATCH", `/api/bots/${bot.id}`, { parkDirectMessages: true }, BOSS);
    await new Promise((r) => setTimeout(r, 300));
    expect((await api("PATCH", `/api/bots/${bot.id}`, { composio: false })).status).toBe(200);
    slowBot.release();
    expect((await slowBot.response).status).toBe(200);
    const slowConfig = slowRequest("PUT", "/api/config", { profile: { name: "Race desk" } }, BOSS);
    await new Promise((r) => setTimeout(r, 300));
    expect((await api("PUT", "/api/config", { decisions: { retentionDays: 365 } })).status).toBe(200);
    slowConfig.release();
    expect((await slowConfig.response).status).toBe(200);

    const rows = (await activity("?what=all")).filter((entry) => entry.type === "admin");
    const botRows = rows.filter((entry) => entry.action === "bot.update" && entry.target?.id === bot.id);
    expect(botRows.map((entry) => [entry.who, entry.changed]).sort()).toEqual([
      [BOSS, ["parkDirectMessages"]],
      ["This computer", ["composio"]],
    ].sort());
    const configRows = rows.filter((entry) => entry.action === "config.update" &&
      (JSON.stringify(entry.after).includes("Race desk") || JSON.stringify(entry.after).includes("365")));
    expect(configRows.map((entry) => [entry.who, entry.changed]).sort()).toEqual([
      [BOSS, ["profile.name"]],
      ["This computer", ["decisions.retentionDays"]],
    ].sort());
  });

  it("records only while the workspace is shared, including the change that ends or starts sharing", async () => {
    expect((await api("PUT", "/api/config", { signIn: { admins: [BOSS], members: [] } }, BOSS)).status).toBe(200);
    expect((await activity("?what=people"))[0]).toMatchObject({ who: BOSS, changed: ["signIn.members"] });
    // (an unused chat-only pairing code from earlier also counts as sharing)
    for (const pairing of (await api("GET", "/api/auth/pairing", undefined, BOSS)).body.pairings ?? []) {
      expect((await api("DELETE", `/api/auth/pairing/${pairing.id}`, undefined, BOSS)).status).toBe(200);
    }
    expect((await api("PUT", "/api/config", { profile: { name: "Solo desk" } }, BOSS)).status).toBe(200);
    expect((await api("POST", "/api/bots", { name: "Solo Wren" }, BOSS)).status).toBe(201);
    let rows = await activity("?what=all");
    expect(JSON.stringify(rows)).not.toContain("Solo desk");
    expect(JSON.stringify(rows)).not.toContain("Solo Wren");
    expect((await api("GET", "/api/admin-activity", undefined, BOSS)).body.recording).toBe(false);

    const opened = await api("POST", "/api/auth/pairing", { label: "Front desk phone", scopes: ["client"] }, BOSS);
    expect(opened.status).toBe(200);
    const paired = await api("POST", "/api/auth/pair", { code: opened.body.code, label: "Front desk phone" });
    expect(paired.status, JSON.stringify(paired.body)).toBe(200);
    const phone = paired.body.session.id as string;
    expect((await api("GET", "/api/admin-activity", undefined, BOSS)).body.recording).toBe(true);
    expect((await api("DELETE", `/api/auth/sessions/${phone}`, undefined, BOSS)).status).toBe(200);
    expect((await api("GET", "/api/admin-activity", undefined, BOSS)).body.recording).toBe(false);
    rows = await activity("?what=session");
    expect(rows.map((entry) => entry.action)).toEqual(expect.arrayContaining(["pairing.create", "session.revoke"]));
    expect(rows.find((entry) => entry.action === "session.revoke")).toMatchObject({ who: BOSS, target: { id: phone, name: "Front desk phone" } });
  });
});