Files
AgentHub/server/__tests__/query-explorer.test.js
T
nntrivi2001 4aae61297f feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
  Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
  with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
  Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
  its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
  status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
  reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
  ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
2026-10-06 17:12:28 +07:00

150 lines
5.8 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
"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"');
});
});