/** * @file Tests the push path and the privacy settings end to end: * `POST /api/hooks/ingest-batch` (off until a token exists, bearer token * checked, sessions tagged `push:`, a session another source owns * refused, the sender's transcript paths dropped, batch limits) with the token * routes `/api/settings/push-token` (shown once, stored only as a hash, * rotate/revoke), and `/api/settings/privacy` (ignored folders dropping hooks, * opt-in secret redaction of stored payloads and summaries, validation, the * same-origin guard). DB, homes and the settings file live in a temp dir. * @author Nguyễn Ngọc Trí Vĩ */ const os = require("node:os"); const fs = require("node:fs"); const path = require("node:path"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-push-ingest-")); process.env.CLAUDE_HOME = path.join(ROOT, ".claude"); process.env.CODEX_HOME = path.join(ROOT, "codex"); process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db"); process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env"); delete process.env.AGENTHUB_PUSH_TOKEN_SHA256; delete process.env.AGENTHUB_REDACT_SECRETS; delete process.env.AGENTHUB_IGNORE_CWD; const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const http = require("node:http"); const express = require("express"); const { db, stmts } = require("../db"); let server; let BASE; before(async () => { const app = express(); app.use(express.json({ limit: "1mb" })); app.use("/api/hooks", require("../routes/hooks")); app.use("/api/settings", require("../routes/settings")); server = http.createServer(app); await new Promise((r) => server.listen(0, "127.0.0.1", r)); BASE = `http://127.0.0.1:${server.address().port}`; }); after(() => { server?.close(); db.close(); fs.rmSync(ROOT, { recursive: true, force: true }); }); async function call(method, p, body, headers = {}) { const res = await fetch(BASE + p, { method, headers: { "Content-Type": "application/json", ...headers }, body: body === undefined ? undefined : JSON.stringify(body), }); return { status: res.status, body: await res.json() }; } const bearer = (t) => ({ Authorization: `Bearer ${t}` }); const batch = (token, body) => call("POST", "/api/hooks/ingest-batch", body, bearer(token)); const hook = (session_id, hook_type = "SessionStart", extra = {}) => ({ hook_type, data: { session_id, cwd: "/remote/proj", ...extra }, }); describe("push ingest", () => { let token; it("is off (503) until a token exists", async () => { const r = await batch("anything", { source: "laptop", events: [hook("p0")] }); assert.equal(r.status, 503); assert.equal(r.body.error.code, "EPUSHDISABLED"); assert.deepEqual((await call("GET", "/api/settings/push-token")).body, { enabled: false, env_owned: false, }); }); it("shows a new token once and stores only its hash", async () => { const r = await call("POST", "/api/settings/push-token"); assert.equal(r.status, 200); token = r.body.token; assert.match(token, /^ahp_[A-Za-z0-9_-]{40,}$/); const status = await call("GET", "/api/settings/push-token"); assert.deepEqual(status.body, { enabled: true, env_owned: false }); const file = fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"); assert.ok(!file.includes(token)); assert.match(file, /^AGENTHUB_PUSH_TOKEN_SHA256=[0-9a-f]{64}$/m); }); it("refuses a missing or wrong token", async () => { assert.equal( (await batch("ahp_wrong", { source: "laptop", events: [hook("p0")] })).status, 401 ); const r = await call("POST", "/api/hooks/ingest-batch", { source: "laptop", events: [] }); assert.equal(r.status, 401); }); it("stores the batch tagged with its source and drops the sender's transcript paths", async () => { const r = await batch(token, { source: "laptop", events: [ hook("p1", "SessionStart", { transcript_path: "/etc/passwd" }), hook("p1", "UserPromptSubmit", { prompt: "hello there" }), { hook_type: "bad type!", data: {} }, ], }); assert.equal(r.status, 200); assert.equal(r.body.source, "push:laptop"); assert.equal(r.body.stored, 2); assert.deepEqual(r.body.results[2], { ok: false, error: "INVALID_INPUT" }); const s = stmts.getSession.get("p1"); assert.equal(s.source, "push:laptop"); assert.ok(!s.transcript_path); const data = db.prepare("SELECT data FROM events WHERE session_id = 'p1'").all(); assert.ok(data.every((e) => !e.data.includes("/etc/passwd"))); }); it("never touches a session another source owns", async () => { await call("POST", "/api/hooks/event", hook("local1")); assert.equal(stmts.getSession.get("local1").source, "local"); const before = db .prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = 'local1'") .get().n; const r = await batch(token, { source: "laptop", events: [hook("local1", "Stop")] }); assert.deepEqual(r.body.results[0], { ok: false, error: "EFOREIGN" }); const after = db .prepare("SELECT COUNT(*) AS n FROM events WHERE session_id = 'local1'") .get().n; assert.equal(after, before); // Nor one a different pushing machine created. const other = await batch(token, { source: "desk", events: [hook("p1", "Stop")] }); assert.deepEqual(other.body.results[0], { ok: false, error: "EFOREIGN" }); }); it("checks the source name and the batch size", async () => { assert.equal((await batch(token, { source: "../x", events: [hook("p2")] })).status, 400); assert.equal((await batch(token, { source: "laptop", events: [] })).status, 400); const many = Array.from({ length: 501 }, (_, i) => hook(`m${i}`)); assert.equal((await batch(token, { source: "laptop", events: many })).status, 400); }); it("stops the old token on rotate, and everything on revoke", async () => { const r = await call("POST", "/api/settings/push-token"); assert.notEqual(r.body.token, token); assert.equal((await batch(token, { source: "laptop", events: [hook("p3")] })).status, 401); assert.equal( (await batch(r.body.token, { source: "laptop", events: [hook("p3")] })).status, 200 ); const off = await call("DELETE", "/api/settings/push-token"); assert.deepEqual(off.body, { ok: true, enabled: false, env_owned: false }); assert.equal( (await batch(r.body.token, { source: "laptop", events: [hook("p4")] })).status, 503 ); }); it("refuses a token write from another site", async () => { const r = await call("POST", "/api/settings/push-token", undefined, { Origin: "https://evil.example", }); assert.equal(r.status, 403); }); }); describe("privacy settings", () => { it("starts off", async () => { const r = await call("GET", "/api/settings/privacy"); assert.deepEqual(r.body, { redact_secrets: false, ignore_cwd: [], env_owned: { redact_secrets: false, ignore_cwd: false }, }); }); it("drops every hook from an ignored folder, and keeps its siblings", async () => { const r = await call("PUT", "/api/settings/privacy", { ignore_cwd: ["/private/work/"] }); assert.equal(r.status, 200); assert.deepEqual(r.body.ignore_cwd, ["/private/work"]); const dropped = await call("POST", "/api/hooks/event", { hook_type: "SessionStart", data: { session_id: "ig1", cwd: "/private/work/app" }, }); assert.deepEqual(dropped.body, { ok: true, ignored: true }); assert.equal(stmts.getSession.get("ig1"), undefined); await call("POST", "/api/hooks/event", { hook_type: "SessionStart", data: { session_id: "ig2", cwd: "/private/work-other" }, }); assert.ok(stmts.getSession.get("ig2")); }); it("redacts stored payloads and summaries once turned on", async () => { const r = await call("PUT", "/api/settings/privacy", { redact_secrets: true }); assert.equal(r.body.redact_secrets, true); await call("POST", "/api/hooks/event", { hook_type: "PreToolUse", data: { session_id: "rd1", cwd: "/w", tool_name: "Bash", tool_input: { command: "curl -H 'Authorization: Bearer abcdefgh12345678' https://x" }, }, }); const rows = db .prepare( "SELECT summary, data FROM events WHERE session_id = 'rd1' AND event_type = 'PreToolUse'" ) .all(); assert.equal(rows.length, 1); assert.ok(!rows[0].data.includes("abcdefgh12345678")); assert.ok(!String(rows[0].summary).includes("abcdefgh12345678")); assert.ok(rows[0].data.includes("[redacted]")); await call("PUT", "/api/settings/privacy", { redact_secrets: false, ignore_cwd: [] }); }); it("refuses a bad list, a bad flag and another site", async () => { assert.equal((await call("PUT", "/api/settings/privacy", { ignore_cwd: ["rel"] })).status, 400); assert.equal((await call("PUT", "/api/settings/privacy", { ignore_cwd: ["/"] })).status, 400); assert.equal( (await call("PUT", "/api/settings/privacy", { redact_secrets: "yes" })).status, 400 ); const r = await call( "PUT", "/api/settings/privacy", { redact_secrets: true }, { Origin: "https://evil.example" } ); assert.equal(r.status, 403); }); });