e657e20b6f
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
588 lines
18 KiB
JavaScript
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)
|
|
}
|
|
})
|