Files
hermes-control-panel/test/admin-logs.integration.test.cjs
2026-06-06 02:02:36 -06:00

588 lines
18 KiB
JavaScript

"use strict"
const { test } = require("node:test")
const assert = require("assert")
const fs = require("fs")
const http = require("http")
const os = require("os")
const path = require("path")
const crypto = require("crypto")
const { spawn } = require("child_process")
const root = path.join(__dirname, "..")
const serverPath = path.join(root, "server.cjs")
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeRequest(options, body = null) {
return new Promise((resolve, reject) => {
const req = http.request(options, (res) => {
let data = ""
res.on("data", (chunk) => { data += chunk })
res.on("end", () => {
let json = null
try { json = JSON.parse(data) } catch { /* ok */ }
resolve({ status: res.statusCode, headers: res.headers, body: json, raw: data })
})
})
req.on("error", reject)
if (body !== null) {
req.write(typeof body === "string" ? body : JSON.stringify(body))
}
req.end()
})
}
function waitForServer(url, proc, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs
const parsed = new URL(url)
const opts = { hostname: parsed.hostname, port: Number(parsed.port), path: parsed.pathname, method: "GET" }
function attempt() {
if (proc.exitCode !== null) {
return reject(new Error(`server process exited with code ${proc.exitCode}`))
}
if (Date.now() > deadline) {
return reject(new Error("timed out waiting for server to become ready"))
}
const req = http.request(opts, (res) => {
let data = ""
res.on("data", (c) => { data += c })
res.on("end", () => { resolve() })
})
req.on("error", () => setTimeout(attempt, 150))
req.end()
}
attempt()
})
}
function setupTempEnv() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-logs-test-"))
const hermesHome = path.join(tmp, ".hermes")
fs.mkdirSync(hermesHome, { recursive: true })
fs.writeFileSync(
path.join(hermesHome, "config.yaml"),
"model:\n provider: openai-codex\n default: gpt-5\nfallback_providers: []\n"
)
const fakeHermes = path.join(tmp, "hermes")
fs.writeFileSync(fakeHermes, `#!/bin/sh
if [ "$1" = "auth" ] && [ "$2" = "list" ]; then
echo "openai-codex (0 credentials):"
elif [ "$1" = "fallback" ] && [ "$2" = "list" ]; then
echo "Primary: none"
elif [ "$1" = "version" ]; then
echo "test-hermes"
else
echo "unsupported: $*" >&2
exit 1
fi
`)
fs.chmodSync(fakeHermes, 0o755)
return { tmp, hermesHome, fakeHermes }
}
async function startServer(databaseUrl, adminUsername, adminPassword, port) {
const { tmp, hermesHome, fakeHermes } = setupTempEnv()
const env = {
...process.env,
HOME: tmp,
HERMES_HOME: hermesHome,
HERMES_EXE: fakeHermes,
HERMES_SETUP_UI_HOST: "127.0.0.1",
HERMES_SETUP_UI_PORT: String(port),
DATABASE_URL: databaseUrl,
HERMES_ADMIN_USERNAME: adminUsername,
HERMES_ADMIN_PASSWORD: adminPassword
}
const proc = spawn(process.execPath, [serverPath], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] })
let stderr = ""
proc.stderr.on("data", (d) => { stderr += d.toString() })
try {
await waitForServer(`http://127.0.0.1:${port}/health`, proc)
} catch (err) {
proc.kill()
throw new Error(`${err.message}\nServer stderr:\n${stderr}`)
}
return { proc, tmp, host: "127.0.0.1", port }
}
function stopServer(proc, tmp) {
proc.kill()
if (tmp) {
try { fs.rmSync(tmp, { recursive: true, force: true }) } catch { /* ok */ }
}
}
async function adminLogin(base, username, password) {
const r = await makeRequest(
{ ...base, path: "/api/admin/login", method: "POST", headers: { "Content-Type": "application/json" } },
{ username, password }
)
assert.strictEqual(r.status, 200, `login failed: ${JSON.stringify(r.body)}`)
const setCookie = r.headers["set-cookie"]
const cookieStr = Array.isArray(setCookie) ? setCookie[0] : setCookie
const match = cookieStr.match(/hermes_admin=([^;]+)/)
assert.ok(match, "could not parse hermes_admin cookie value")
return `hermes_admin=${match[1]}`
}
// Helper to create an API user + complete a usage event via direct DB queries
async function createUserAndEvent(pool, { displayName = "Test User", apiUserId, apiUserName } = {}) {
const { createApiUser } = require("../lib/api-users-store.cjs")
const {
beginUsageEvent,
completeUsageEvent,
} = require("../lib/audit-store.cjs")
const { user } = await createApiUser(pool, {
displayName,
allowPre: true,
allowPost: true,
requestsPerMinute: 60,
monthlyTokenLimit: 1000000,
expiresAt: null,
})
const { rows } = await pool.query(
"SELECT id FROM api_keys WHERE api_user_id = $1 AND revoked_at IS NULL",
[user.id]
)
const keyId = rows[0].id
const eventId = crypto.randomUUID()
const now = new Date()
await beginUsageEvent(pool, {
id: eventId,
apiUserId: user.id,
apiUserName: user.display_name,
apiKeyId: keyId,
route: "pre",
requestStartedAt: now,
model: "test-model",
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
})
await completeUsageEvent(pool, eventId, {
requestCompletedAt: new Date(),
httpStatus: 200,
model: "test-model",
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
latencyMs: 50,
requestJson: { model: "test-model", messages: [{ role: "user", content: "hi" }] },
requestText: null,
responseJson: { choices: [{ message: { content: "hello" } }] },
responseText: null,
responseContentType: "application/json",
streaming: false,
partial: false,
})
return { user, keyId, eventId }
}
// ─── Tests ────────────────────────────────────────────────────────────────────
// Use distinct ports per test to allow parallel isolation
const BASE_PORT = 19800
test("admin logs download — unauthenticated returns 401", { timeout: 30000 }, async (t) => {
const dbUrl = process.env.TEST_DATABASE_URL
if (!dbUrl) {
t.skip("TEST_DATABASE_URL not set — skipping integration test")
return
}
const PORT = BASE_PORT
const USERNAME = "admin"
const PASSWORD = "test-password-16chars!"
let serverHandle = null
try {
serverHandle = await startServer(dbUrl, USERNAME, PASSWORD, PORT)
} catch (err) {
t.skip(`Could not start server: ${err.message}`)
return
}
const { proc, tmp, host, port } = serverHandle
const base = { hostname: host, port }
try {
const r = await makeRequest({ ...base, path: "/api/admin/logs/download", method: "GET" })
assert.strictEqual(r.status, 401, `expected 401, got ${r.status}`)
assert.ok(r.body?.error, "expected error field in body")
} finally {
stopServer(proc, tmp)
}
})
test("admin logs download — admin can download all logs", { timeout: 30000 }, async (t) => {
const dbUrl = process.env.TEST_DATABASE_URL
if (!dbUrl) {
t.skip("TEST_DATABASE_URL not set — skipping integration test")
return
}
const PORT = BASE_PORT + 1
const USERNAME = "admin"
const PASSWORD = "test-password-16chars!"
let serverHandle = null
try {
serverHandle = await startServer(dbUrl, USERNAME, PASSWORD, PORT)
} catch (err) {
t.skip(`Could not start server: ${err.message}`)
return
}
const { proc, tmp, host, port } = serverHandle
const base = { hostname: host, port }
try {
// Create a DB pool to set up test data
const { Pool } = require("pg")
const { runMigrations } = require("../lib/db.cjs")
const pool = new Pool({ connectionString: dbUrl })
try {
await runMigrations(pool)
const { user } = await createUserAndEvent(pool, { displayName: "Download Test User" })
const cookie = await adminLogin(base, USERNAME, PASSWORD)
const r = await makeRequest({
...base,
path: "/api/admin/logs/download",
method: "GET",
headers: { Cookie: cookie },
})
assert.strictEqual(r.status, 200, `expected 200, got ${r.status}: ${r.raw}`)
assert.ok(
r.headers["content-type"]?.includes("application/x-ndjson"),
`expected application/x-ndjson content-type, got: ${r.headers["content-type"]}`
)
// Parse JSONL lines
const lines = r.raw
.trim()
.split("\n")
.filter((l) => l.trim())
.map((l) => JSON.parse(l))
assert.ok(lines.length >= 1, "should have at least one log line")
const entry = lines.find((l) => l.api_user_id === user.id)
assert.ok(entry, `should find entry for user ${user.id}`)
assert.strictEqual(entry.api_user_name, "Download Test User")
} finally {
await pool.end()
}
} finally {
stopServer(proc, tmp)
}
})
test("admin logs download — filter by api_user_id", { timeout: 30000 }, async (t) => {
const dbUrl = process.env.TEST_DATABASE_URL
if (!dbUrl) {
t.skip("TEST_DATABASE_URL not set — skipping integration test")
return
}
const PORT = BASE_PORT + 2
const USERNAME = "admin"
const PASSWORD = "test-password-16chars!"
let serverHandle = null
try {
serverHandle = await startServer(dbUrl, USERNAME, PASSWORD, PORT)
} catch (err) {
t.skip(`Could not start server: ${err.message}`)
return
}
const { proc, tmp, host, port } = serverHandle
const base = { hostname: host, port }
try {
const { Pool } = require("pg")
const { runMigrations } = require("../lib/db.cjs")
const pool = new Pool({ connectionString: dbUrl })
try {
await runMigrations(pool)
const { user: user1 } = await createUserAndEvent(pool, { displayName: "Filter User A" })
const { user: user2 } = await createUserAndEvent(pool, { displayName: "Filter User B" })
const cookie = await adminLogin(base, USERNAME, PASSWORD)
// Filter by user1's ID only
const r = await makeRequest({
...base,
path: `/api/admin/logs/download?api_user_id=${user1.id}`,
method: "GET",
headers: { Cookie: cookie },
})
assert.strictEqual(r.status, 200, `expected 200, got ${r.status}: ${r.raw}`)
const lines = r.raw
.trim()
.split("\n")
.filter((l) => l.trim())
.map((l) => JSON.parse(l))
assert.ok(lines.length >= 1, "should have at least one result")
for (const line of lines) {
assert.strictEqual(line.api_user_id, user1.id, `all results should be for user1, got ${line.api_user_id}`)
}
const hasUser2 = lines.some((l) => l.api_user_id === user2.id)
assert.strictEqual(hasUser2, false, "should not include user2 events")
} finally {
await pool.end()
}
} finally {
stopServer(proc, tmp)
}
})
test("admin logs download — filter by start/end dates", { timeout: 30000 }, async (t) => {
const dbUrl = process.env.TEST_DATABASE_URL
if (!dbUrl) {
t.skip("TEST_DATABASE_URL not set — skipping integration test")
return
}
const PORT = BASE_PORT + 3
const USERNAME = "admin"
const PASSWORD = "test-password-16chars!"
let serverHandle = null
try {
serverHandle = await startServer(dbUrl, USERNAME, PASSWORD, PORT)
} catch (err) {
t.skip(`Could not start server: ${err.message}`)
return
}
const { proc, tmp, host, port } = serverHandle
const base = { hostname: host, port }
try {
const { Pool } = require("pg")
const { runMigrations } = require("../lib/db.cjs")
const { createApiUser } = require("../lib/api-users-store.cjs")
const pool = new Pool({ connectionString: dbUrl })
try {
await runMigrations(pool)
// Create an API user
const { user } = await createApiUser(pool, {
displayName: "Date Filter User",
allowPre: true,
allowPost: true,
requestsPerMinute: 60,
monthlyTokenLimit: 1000000,
expiresAt: null,
})
const { rows: keyRows } = await pool.query(
"SELECT id FROM api_keys WHERE api_user_id = $1 AND revoked_at IS NULL",
[user.id]
)
const keyId = keyRows[0].id
// Insert two events with different timestamps directly
const oldTime = new Date("2020-01-15T12:00:00Z")
const newTime = new Date("2024-06-15T12:00:00Z")
for (const [ts, label] of [[oldTime, "old-event"], [newTime, "new-event"]]) {
const eventId = crypto.randomUUID()
await pool.query(
`INSERT INTO usage_events
(id, api_user_id, api_user_name, api_key_id, route, request_started_at,
prompt_tokens, completion_tokens, total_tokens, audit_complete,
request_completed_at, http_status, model, latency_ms)
VALUES ($1, $2, $3, $4, 'pre', $5, 10, 5, 15, true, $6, 200, 'test', 50)`,
[eventId, user.id, user.display_name, keyId, ts, new Date(ts.getTime() + 100)]
)
await pool.query(
`INSERT INTO message_logs
(usage_event_id, request_json, response_json, streaming, partial, delete_after)
VALUES ($1, $2, $3, false, false, now() + '90 days'::interval)`,
[eventId, JSON.stringify({ label }), JSON.stringify({ ok: true })]
)
}
const cookie = await adminLogin(base, USERNAME, PASSWORD)
// Filter to only include 2024 event
const start = "2024-01-01T00:00:00Z"
const end = "2025-01-01T00:00:00Z"
const r = await makeRequest({
...base,
path: `/api/admin/logs/download?api_user_id=${user.id}&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`,
method: "GET",
headers: { Cookie: cookie },
})
assert.strictEqual(r.status, 200, `expected 200, got ${r.status}: ${r.raw}`)
const lines = r.raw
.trim()
.split("\n")
.filter((l) => l.trim())
.map((l) => JSON.parse(l))
assert.ok(lines.length >= 1, "should have at least one result")
for (const line of lines) {
const ts = new Date(line.request_started_at)
assert.ok(ts >= new Date(start), `event should be on or after start: ${ts.toISOString()}`)
assert.ok(ts < new Date(end), `event should be before end: ${ts.toISOString()}`)
}
// The old event should not be present
const hasOldEvent = lines.some((l) => {
const ts = new Date(l.request_started_at)
return ts.getFullYear() === 2020
})
assert.strictEqual(hasOldEvent, false, "should not include 2020 event")
} finally {
await pool.end()
}
} finally {
stopServer(proc, tmp)
}
})
test("admin logs download — invalid date params return 400", { timeout: 30000 }, async (t) => {
const dbUrl = process.env.TEST_DATABASE_URL
if (!dbUrl) {
t.skip("TEST_DATABASE_URL not set — skipping integration test")
return
}
const PORT = BASE_PORT + 4
const USERNAME = "admin"
const PASSWORD = "test-password-16chars!"
let serverHandle = null
try {
serverHandle = await startServer(dbUrl, USERNAME, PASSWORD, PORT)
} catch (err) {
t.skip(`Could not start server: ${err.message}`)
return
}
const { proc, tmp, host, port } = serverHandle
const base = { hostname: host, port }
try {
const cookie = await adminLogin(base, USERNAME, PASSWORD)
// Invalid date string for start
await t.test("invalid start date returns 400", async () => {
const r = await makeRequest({
...base,
path: "/api/admin/logs/download?start=not-a-date",
method: "GET",
headers: { Cookie: cookie },
})
assert.strictEqual(r.status, 400, `expected 400, got ${r.status}`)
assert.ok(r.body?.error, "expected error field in body")
})
// start after end returns 400
await t.test("start after end returns 400", async () => {
const r = await makeRequest({
...base,
path: "/api/admin/logs/download?start=2025-01-01T00:00:00Z&end=2024-01-01T00:00:00Z",
method: "GET",
headers: { Cookie: cookie },
})
assert.strictEqual(r.status, 400, `expected 400, got ${r.status}`)
assert.ok(r.body?.error, "expected error field in body")
})
// Invalid end date string
await t.test("invalid end date returns 400", async () => {
const r = await makeRequest({
...base,
path: "/api/admin/logs/download?end=not-a-date",
method: "GET",
headers: { Cookie: cookie },
})
assert.strictEqual(r.status, 400, `expected 400, got ${r.status}`)
assert.ok(r.body?.error, "expected error field in body")
})
} finally {
stopServer(proc, tmp)
}
})
test("admin logs download — response headers are correct", { timeout: 30000 }, async (t) => {
const dbUrl = process.env.TEST_DATABASE_URL
if (!dbUrl) {
t.skip("TEST_DATABASE_URL not set — skipping integration test")
return
}
const PORT = BASE_PORT + 5
const USERNAME = "admin"
const PASSWORD = "test-password-16chars!"
let serverHandle = null
try {
serverHandle = await startServer(dbUrl, USERNAME, PASSWORD, PORT)
} catch (err) {
t.skip(`Could not start server: ${err.message}`)
return
}
const { proc, tmp, host, port } = serverHandle
const base = { hostname: host, port }
try {
const cookie = await adminLogin(base, USERNAME, PASSWORD)
const r = await makeRequest({
...base,
path: "/api/admin/logs/download",
method: "GET",
headers: { Cookie: cookie },
})
assert.strictEqual(r.status, 200, `expected 200, got ${r.status}: ${r.raw}`)
// Content-Type must be application/x-ndjson
assert.ok(
r.headers["content-type"]?.includes("application/x-ndjson"),
`expected application/x-ndjson, got: ${r.headers["content-type"]}`
)
// Content-Disposition must be attachment with a filename matching hermes-audit-YYYY-MM-DD.jsonl
const disposition = r.headers["content-disposition"]
assert.ok(disposition, "expected Content-Disposition header")
assert.ok(
disposition.includes("attachment"),
`expected 'attachment' in Content-Disposition, got: ${disposition}`
)
assert.ok(
/hermes-audit-\d{4}-\d{2}-\d{2}\.jsonl/.test(disposition),
`expected filename matching hermes-audit-YYYY-MM-DD.jsonl, got: ${disposition}`
)
} finally {
stopServer(proc, tmp)
}
})