server/admin-activity.test.ts
Rama main · réplica de lectura del repositorio original.
import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
activityCsv,
activityEntries,
adminActivityFileFor,
appendAdminAction,
auditValues,
botAuditSnapshot,
botChangeRows,
changedPaths,
configChangeRows,
flushAdminActivity,
parseActivityWhat,
pruneAdminActivity,
readAdminActivityRange,
sharedSignIn,
signInListsOf,
TEAM_BOT_AUDIT_FIELDS,
type AdminActionRow,
} from "./admin-activity.ts";
import { bindDecisionRetention, DEFAULT_DECISION_RETENTION_DAYS, type DecisionRow } from "./decision-log.ts";
import { removeTempDir } from "./testing/cleanup.ts";
const dirs: string[] = [];
const tempDir = () => {
const dir = mkdtempSync(join(tmpdir(), "omb-admin-activity-"));
dirs.push(dir);
return dir;
};
afterEach(async () => {
bindDecisionRetention(() => DEFAULT_DECISION_RETENTION_DAYS);
for (const dir of dirs.splice(0)) await removeTempDir(dir);
});
const BOSS = { kind: "session" as const, sessionId: "s1", label: "Boss's laptop", email: "boss@example.test" };
describe("what a row records", () => {
it("names the paths that changed, never a secret's value", () => {
const before = { anthropic: { key: "sk-ant-old-secret-value-1234567890" }, features: { browser: true }, profile: { name: "A" } };
const after = { anthropic: { key: "sk-ant-new-secret-value-0987654321" }, features: { browser: false }, profile: { name: "A" }, decisions: { retentionDays: 365 } };
expect(changedPaths(before, after)).toEqual(["anthropic.key", "decisions.retentionDays", "features.browser"]);
const rows = configChangeRows(before, after);
expect(rows.map((row) => [row.category, row.action, row.changed])).toEqual([
["engine", "engine.update", ["anthropic.key"]],
["config", "config.update", ["decisions.retentionDays", "features.browser"]],
]);
const engine = auditValues(rows[0]!.after!);
expect(engine).toEqual({ "anthropic.key": "[hidden]" });
expect(JSON.stringify(auditValues(rows[0]!.before!))).not.toContain("sk-ant");
expect(auditValues(rows[1]!.after!)).toEqual({ "decisions.retentionDays": 365, "features.browser": false });
expect(auditValues(rows[1]!.before!)).toEqual({ "decisions.retentionDays": null, "features.browser": true });
});
it("files people, budgets and MCP servers under their own heading, headers and env hidden", () => {
const rows = configChangeRows(
{ signIn: { admins: ["boss@example.test"], members: [] }, budgets: { monthlyUsd: 100 } },
{
signIn: { admins: ["boss@example.test"], members: ["ada@example.test"] },
budgets: { monthlyUsd: 250 },
mcpServers: { github: { url: "https://mcp.example.test", headers: { Authorization: "Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789" }, env: { TEAM: "ops" } } },
},
);
expect(rows.map((row) => row.category).sort()).toEqual(["budget", "mcp", "people"]);
const people = rows.find((row) => row.category === "people")!;
expect(people.changed).toEqual(["signIn.members"]);
expect(auditValues(people.after!)).toEqual({ "signIn.members": ["ada@example.test"] });
const mcp = auditValues(rows.find((row) => row.category === "mcp")!.after!);
expect(mcp).toEqual({ "mcpServers.github.env": { TEAM: "[hidden]" }, "mcpServers.github.headers": { Authorization: "[hidden]" }, "mcpServers.github.url": "https://mcp.example.test" });
expect(auditValues({ "instances.grok.environment": { KEY: "x" }, "box.token": "t", "rooms.maxTokens": 9 }))
.toEqual({ "instances.grok.environment": { KEY: "[hidden]" }, "box.token": "[hidden]", "rooms.maxTokens": 9 });
});
it("names each key saved for an OpenCode provider, never its value", () => {
const rows = configChangeRows({ opencodeGo: { apiKey: "" } }, {
opencodeGo: { apiKey: "", providerKeys: { VENICE_API_KEY: "venice-secret", GROQ_TOKEN: "groq-secret", DEEPSEEK_KEY: "deepseek-secret" } },
});
expect(rows.map((row) => row.category)).toEqual(["engine"]);
expect(rows[0]!.changed).toEqual(["opencodeGo.providerKeys.DEEPSEEK_KEY", "opencodeGo.providerKeys.GROQ_TOKEN", "opencodeGo.providerKeys.VENICE_API_KEY"]);
expect(JSON.stringify(auditValues(rows[0]!.after!))).not.toContain("secret");
expect(JSON.stringify(auditValues({ "opencodeGo.providerKeys": { VENICE_API_KEY: "venice-secret" } }))).not.toContain("secret");
});
it("hides a url server's sign-in app secret, keeps its client id", () => {
const rows = configChangeRows({ mcpServers: {} }, { mcpServers: {
corp: { url: "https://mcp.example.test", oauth: { clientId: "corp-app", clientSecret: "app-secret-value", scopes: ["mcp"] } },
} });
expect(auditValues(rows[0]!.after!)).toEqual({
"mcpServers.corp.oauth": { clientId: "corp-app", clientSecret: "[hidden]", scopes: ["mcp"] },
"mcpServers.corp.url": "https://mcp.example.test",
});
});
it("hides a credential passed as a flag or in a URL", () => {
const rows = configChangeRows({ mcpServers: {} }, { mcpServers: {
stripe: { command: "npx", args: ["-y", "@acme/mcp", "--api-key", "acme_live_9f8e7d6c5b4a3f2e1d0c", "--token=tok_live_123456", "--verbose", "--port", "8080"] },
remote: { url: "https://mcp.example.test/sse?key=abc123def456ghi&mode=fast&access_token=xyz987" },
} });
const values = JSON.stringify(auditValues(rows[0]!.after!));
for (const secret of ["acme_live_9f8e7d6c5b4a3f2e1d0c", "tok_live_123456", "abc123def456ghi", "xyz987"]) expect(values).not.toContain(secret);
const more = JSON.stringify(auditValues({
short: ["-k", "acme_live_9f8e7d6c5b4a3f2e1d0c", "-p", "8080"],
path: "https://mcp.zapier.com/api/mcp/s/acme_live_9f8e7d6c5b4a3f2e1d0c/sse",
remote: "https://acme_live_9f8e7d6c5b4a3f2e1d0c@mcp.example.com/sse",
slug: "https://example.com/docs/getting-started-guide-2024",
uuid: "https://api.example.com/items/4575b1f7-c16c-4afa-82ce-bcc2fdc70374",
}));
expect(more).not.toContain("acme_live");
expect(more).toContain('["-k","[hidden]","-p","8080"]');
expect(more).toContain("https://mcp.zapier.com/api/mcp/s/[hidden]/sse");
expect(more).toContain("https://[hidden]@mcp.example.com/sse");
expect(more).toContain("getting-started-guide-2024");
expect(more).toContain("4575b1f7-c16c-4afa-82ce-bcc2fdc70374");
expect(values).toContain('"--api-key","[hidden]","--token=[hidden]","--verbose","--port","8080"');
expect(values).toContain("?key=[hidden]&mode=fast&access_token=[hidden]");
});
it("compares only the fields a request named", () => {
const before = botAuditSnapshot({ approvalMode: "ask", composio: true });
const after = botAuditSnapshot({ approvalMode: "auto", composio: false });
expect(botChangeRows({ id: "b" }, before, after, ["composio"]).map((row) => row.changed)).toEqual([["composio"]]);
expect(botChangeRows({ id: "b" }, before, after).map((row) => row.changed)).toEqual([["approvalMode", "composio"]]);
});
it("follows a bot's helper-model switch: putting a bot back on the helper decides where its text may go", () => {
const off = botAuditSnapshot({ id: "b", helperModel: false });
const on = botAuditSnapshot({ id: "b", helperModel: true });
const unset = botAuditSnapshot({ id: "b" });
expect(botChangeRows({ id: "b", name: "Legal" }, off, on)).toEqual([
{ category: "bot", action: "bot.update", target: { kind: "bot", id: "b", name: "Legal" }, changed: ["helperModel"], before: { helperModel: false }, after: { helperModel: true } },
]);
expect(botChangeRows({ id: "b" }, unset, off).map((row) => [row.action, row.changed, row.after])).toEqual([["bot.update", ["helperModel"], { helperModel: false }]]);
expect(botChangeRows({ id: "b" }, off, off)).toEqual([]);
});
it("records that a Team bot's instructions changed without keeping their text", () => {
const before = botAuditSnapshot({ id: "b", soul: "Keep the vault code 4242 secret." }, TEAM_BOT_AUDIT_FIELDS);
const after = botAuditSnapshot({ id: "b", soul: "Be brief." }, TEAM_BOT_AUDIT_FIELDS);
const rows = botChangeRows({ id: "b" }, before, after, undefined, TEAM_BOT_AUDIT_FIELDS);
expect(rows.map((row) => row.changed)).toEqual([["soul"]]);
expect(JSON.stringify(rows)).not.toMatch(/4242|Be brief/);
expect(botChangeRows({ id: "b" }, after, botAuditSnapshot({ id: "b", soul: "Be brief." }, TEAM_BOT_AUDIT_FIELDS), undefined, TEAM_BOT_AUDIT_FIELDS)).toEqual([]);
});
it("records a bot moving to another VPS server, and back to the default", () => {
const own = botAuditSnapshot({ id: "b", cloudBackend: "vps", vpsAlias: "alpha-vps" });
const onDefault = botAuditSnapshot({ id: "b", cloudBackend: "vps" });
expect(botChangeRows({ id: "b" }, onDefault, own).map((row) => [row.changed, row.before, row.after]))
.toEqual([[["vpsAlias"], { vpsAlias: null }, { vpsAlias: "alpha-vps" }]]);
expect(botChangeRows({ id: "b" }, own, onDefault).map((row) => row.changed)).toEqual([["vpsAlias"]]);
});
it("knows when more than one person signs in", () => {
expect(sharedSignIn({ admins: ["boss@example.test"], members: [] })).toBe(false);
expect(sharedSignIn({ admins: [], members: [] })).toBe(false);
expect(sharedSignIn({ admins: ["boss@example.test"], members: ["ada@example.test"] })).toBe(true);
expect(sharedSignIn({ admins: ["boss@example.test", "cto@example.test"], members: [] })).toBe(true);
expect(sharedSignIn({ admins: ["@example.test"], members: [] })).toBe(true);
expect(signInListsOf({ signIn: { admins: ["a@x.test", 3], members: "nope" } })).toEqual({ admins: ["a@x.test"], members: [] });
expect(signInListsOf(null)).toEqual({ admins: [], members: [] });
});
it("splits a bot's audience from its other permissions and ignores display fields", () => {
const before = botAuditSnapshot({ id: "b", color: "red", approvalMode: "ask" });
const after = botAuditSnapshot({ id: "b", color: "blue", approvalMode: "auto", visibility: { people: ["ada@example.test"] } });
const rows = botChangeRows({ id: "b", name: "Payroll" }, before, after);
expect(rows).toEqual([
{ category: "visibility", action: "visibility.update", target: { kind: "bot", id: "b", name: "Payroll" }, changed: ["visibility"],
before: { visibility: "everyone" }, after: { visibility: { people: ["ada@example.test"] } } },
{ category: "bot", action: "bot.update", target: { kind: "bot", id: "b", name: "Payroll" }, changed: ["approvalMode"], before: { approvalMode: "ask" }, after: { approvalMode: "auto" } },
]);
expect(botChangeRows({ id: "b" }, botAuditSnapshot({ color: "red" }), botAuditSnapshot({ color: "blue" }))).toEqual([]);
});
});
describe("the monthly file", () => {
it("appends 0600 rows, reads a range back, and prunes by the decision log's window", async () => {
const dir = tempDir();
const old = new Date(Date.UTC(2025, 0, 10));
const recent = new Date();
appendAdminAction(dir, { at: old.toISOString(), category: "config", action: "config.update", actor: { kind: "loopback" } });
await flushAdminActivity(dir);
appendAdminAction(dir, { category: "people", action: "people.update", actor: BOSS, before: { "signIn.members": [] }, after: { "signIn.members": ["ada@example.test"] } });
await flushAdminActivity(dir);
// A write in a new hour prunes: the default window already covers January 2025 → gone.
expect(existsSync(adminActivityFileFor(dir, old))).toBe(false);
const file = adminActivityFileFor(dir, recent);
if (process.platform !== "win32") expect(statSync(file).mode & 0o777).toBe(0o600);
const rows = readAdminActivityRange(dir, { from: new Date(recent.getTime() - 60_000), to: new Date(recent.getTime() + 60_000) });
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ category: "people", actor: BOSS });
});
it("prunes a quiet log on the server's timer, by the same window", async () => {
const dir = tempDir();
const old = new Date(Date.UTC(2024, 0, 15));
mkdirSync(join(dir, "admin-activity"), { recursive: true });
writeFileSync(adminActivityFileFor(dir, old), JSON.stringify({ at: old.toISOString(), category: "bot", action: "bot.create", actor: { kind: "cli" } }) + "\n");
expect(await pruneAdminActivity(dir, 3650)).toEqual([]);
expect(await pruneAdminActivity(dir, 180)).toEqual(["2024-01.ndjson"]);
});
it("keeps an old month while the configured window still covers it", async () => {
const dir = tempDir();
bindDecisionRetention(() => 3650);
const old = new Date(Date.UTC(2024, 5, 1));
mkdirSync(join(dir, "admin-activity"), { recursive: true });
writeFileSync(adminActivityFileFor(dir, old), JSON.stringify({ at: old.toISOString(), category: "bot", action: "bot.create", actor: { kind: "cli" } }) + "\n");
appendAdminAction(dir, { category: "config", action: "config.update", actor: { kind: "loopback" } });
await flushAdminActivity(dir);
expect(readFileSync(adminActivityFileFor(dir, old), "utf8")).toContain("bot.create");
});
});
describe("the Activity view", () => {
const decisions: DecisionRow[] = [
{ at: "2026-09-20T10:00:00.000Z", threadId: "t1", decision: "auto-approved", source: "rule" as DecisionRow["source"], tool: "Read" },
{ at: "2026-09-20T11:00:00.000Z", threadId: "t1", decision: "user-approved", source: "user", tool: "Bash", summary: "=cmd|' /C calc'!A0", botName: "Ops",
actor: { kind: "session", sessionId: "s2", label: "Ada's phone", email: "ada@example.test" } },
];
const actions: AdminActionRow[] = [
{ at: "2026-09-21T09:00:00.000Z", category: "visibility", action: "visibility.update", target: { kind: "bot", id: "b", name: "Payroll" }, actor: BOSS,
changed: ["visibility"], before: { visibility: "everyone" }, after: { visibility: "admins" } },
{ at: "2026-09-19T09:00:00.000Z", category: "people", action: "people.update", actor: { kind: "cli" } },
];
it("shows admin actions and people's answers newest first, filtered by who and what", () => {
const all = activityEntries(decisions, actions, { what: "all" });
expect(all.map((entry) => [entry.type, entry.who, entry.what])).toEqual([
["admin", "boss@example.test", "visibility"],
["approval", "ada@example.test", "user-approved"],
["admin", "Command line", "people"],
]);
expect(activityEntries(decisions, actions, { what: "decisions" })).toHaveLength(2);
expect(activityEntries(decisions, actions, { what: "approvals" }).map((entry) => entry.who)).toEqual(["ada@example.test"]);
expect(activityEntries(decisions, actions, { what: "visibility" })).toHaveLength(1);
expect(activityEntries(decisions, actions, { what: "all", who: "COMMAND" }).map((entry) => entry.what)).toEqual(["people"]);
expect(parseActivityWhat(null)).toBe("all");
expect(parseActivityWhat("budget")).toBe("budget");
expect(parseActivityWhat("nonsense")).toBeNull();
});
it("carries coded phone summaries into the global activity view without changing CSV words", () => {
const row: DecisionRow = { ...decisions[0]!, summary: "allowed PHONE_CREATE_EVENT", summaryCode: "chat.notice.phone.allowed", summaryParams: { tool: "PHONE_CREATE_EVENT" } };
const entries = activityEntries([row], [], { what: "decisions" });
expect(entries).toEqual([expect.objectContaining({ summary: row.summary, summaryCode: row.summaryCode, summaryParams: row.summaryParams })]);
expect(activityCsv(entries)).toContain("allowed PHONE_CREATE_EVENT");
});
it("exports CSV with formula cells neutralised", () => {
const csv = activityCsv(activityEntries(decisions, actions, { what: "all" }));
const lines = csv.trim().split("\n");
expect(lines[0]).toBe("time,type,who,what,action,target,changed,before,after,bot,tool,summary,thread");
expect(lines[1]).toContain("visibility.update,Payroll,visibility");
expect(csv).toContain(",'=cmd|' /C calc'!A0,");
});
});