/** * @file The Query Explorer, GET /api/query: entity switch, filters (text, tool, * status, date range, cwd), the sort ALLOWLIST (a column or direction outside * it is a 400, never SQL), pagination, and the CSV/JSON exports — RFC 4180 * quoting and the formula-injection guard. * @author Nguyễn Ngọc Trí Vĩ */ "use strict"; const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const http = require("node:http"); const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "query-explorer-"))); process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db"); process.env.CLAUDE_HOME = path.join(ROOT, "claude"); process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data"); process.env.DASHBOARD_REMOTE_SYNC_MS = "0"; process.env.DASHBOARD_LIVENESS_PROBE = "0"; const { createApp } = require("../index"); const { db } = require("../db"); const { csvCell } = require("../routes/query"); let server; let base; async function get(url) { const res = await fetch(`${base}${url}`); const text = await res.text(); let body = text; try { body = JSON.parse(text); } catch { /* CSV */ } return { status: res.status, headers: res.headers, body }; } function session(id, name, status, tool, cwd, startedAt) { db.prepare( "INSERT INTO sessions (id, name, status, tool, cwd, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)" ).run(id, name, status, tool, cwd, startedAt, startedAt); } before(async () => { server = http.createServer(createApp()); await new Promise((r) => server.listen(0, "127.0.0.1", r)); base = `http://127.0.0.1:${server.address().port}`; session("q1", "=HYPERLINK(evil)", "completed", "claude", "/w/app", "2026-09-01T10:00:00.000Z"); session("q2", 'says "hi", ok', "active", "codex", "/w/app/sub", "2026-09-05T10:00:00.000Z"); session("q3", "100%_done", "error", "claude", "/w/apple", "2026-09-10T10:00:00.000Z"); db.prepare( "INSERT INTO agents (id, session_id, name, type, status, started_at) VALUES ('ag1', 'q1', 'main', 'main', 'completed', '2026-09-01T10:00:00.000Z')" ).run(); db.prepare( "INSERT INTO events (session_id, event_type, tool_name, summary, created_at) VALUES ('q2', 'PreToolUse', 'Bash', 'line1\nline2', '2026-09-05T11:00:00.000Z')" ).run(); }); after(() => { server.close(); fs.rmSync(ROOT, { recursive: true, force: true }); }); const ids = (r) => r.body.rows.map((x) => x.id); describe("GET /api/query", () => { it("lists sessions newest first by default, paged", async () => { const r = await get("/api/query"); assert.equal(r.status, 200); assert.equal(r.body.entity, "sessions"); assert.deepEqual(ids(r), ["q3", "q2", "q1"]); assert.equal(r.body.total, 3); const p = await get("/api/query?limit=1&offset=1"); assert.deepEqual(ids(p), ["q2"]); assert.equal(p.body.total, 3); }); it("filters by text (wildcards literal), tool, status, range and cwd subtree", async () => { assert.deepEqual(ids(await get("/api/query?q=%25_")), ["q3"]); assert.deepEqual(ids(await get("/api/query?tool=codex")), ["q2"]); assert.deepEqual(ids(await get("/api/query?status=error")), ["q3"]); assert.deepEqual(ids(await get("/api/query?from=2026-09-02&to=2026-09-06")), ["q2"]); assert.deepEqual(ids(await get("/api/query?cwd=/w/app&sort=started_at&dir=asc")), ["q1", "q2"]); }); it("sorts only by an allowlisted column and direction", async () => { assert.deepEqual(ids(await get("/api/query?sort=name&dir=asc")), ["q3", "q1", "q2"]); for (const bad of [ "sort=id;DROP TABLE sessions", "sort=summary", "dir=sideways", "entity=lanes", "status=bogus", "tool=../x", "from=2026-02-31", "format=xml", ]) { const r = await get(`/api/query?${bad}`); assert.equal(r.status, 400, bad); } assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sessions").get().n, 3); }); it("queries events and agents with their own columns", async () => { const e = await get("/api/query?entity=events&q=Bash"); assert.equal(e.body.total, 1); assert.equal(e.body.rows[0].tool, "codex"); assert.equal((await get("/api/query?entity=events&status=active")).status, 400); const a = await get("/api/query?entity=agents&status=completed"); assert.deepEqual(ids(a), ["ag1"]); }); it("exports CSV with quoting and the formula guard", async () => { const r = await get("/api/query?format=csv&sort=started_at&dir=asc"); assert.equal(r.status, 200); assert.match(r.headers.get("content-type"), /text\/csv/); assert.match(r.headers.get("content-disposition"), /attachment; filename="agenthub-sessions-/); assert.equal(r.headers.get("x-truncated"), "0"); const lines = r.body.replace(/^\uFEFF/, "").split("\r\n"); assert.equal(lines[0], "id,name,status,tool,model,cwd,started_at,ended_at"); assert.ok(lines[1].startsWith("q1,'=HYPERLINK(evil),completed")); assert.ok(lines[2].startsWith('q2,"says ""hi"", ok",active')); const ev = await get("/api/query?entity=events&format=csv"); assert.ok(ev.body.includes('"line1\nline2"')); }); it("exports JSON with the total and the cap", async () => { const r = await get("/api/query?format=json&tool=claude"); assert.equal(r.body.total, 2); assert.equal(r.body.truncated, false); assert.equal(r.body.cap, 50_000); assert.equal(r.body.rows.length, 2); }); }); describe("csvCell", () => { it("guards every formula lead and leaves numbers and plain text alone", () => { for (const lead of ["=", "+", "-", "@", "\t"]) assert.equal(csvCell(`${lead}1`)[0], "'"); assert.equal(csvCell(-5), "-5"); assert.equal(csvCell(null), ""); assert.equal(csvCell("plain"), "plain"); assert.equal(csvCell("-1,2"), '"\'-1,2"'); }); });