const http = require("node:http"); const { setTimeout: delay } = require("node:timers/promises"); const { getCpuPercent, getDisk, collectMetrics } = require("../metrics.js"); const TOKEN = "test-token"; let logtoServer; let logtoUrl; let logtoDelayMs; let server; let baseUrl; let handler; // Stand-in for the Logto .well-known endpoint, with a settable delay so we can // simulate a slow IdP without touching the network. function startLogtoStub() { logtoDelayMs = 0; logtoServer = http.createServer(async (req, res) => { if (logtoDelayMs > 0) await delay(logtoDelayMs); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ issuer: "http://127.0.0.1/oidc" })); }); return new Promise((resolve) => { logtoServer.listen(0, "127.0.0.1", () => { const { port } = logtoServer.address(); logtoUrl = `http://127.0.0.1:${port}/oidc/.well-known/openid-configuration`; resolve(); }); }); } function startServer() { // Fresh import so module-level constants pick up the stubbed env. delete require.cache[require.resolve("../index.js")]; ({ handler } = require("../index.js")); server = http.createServer(handler); return new Promise((resolve) => { server.listen(0, () => { const { port } = server.address(); baseUrl = `http://127.0.0.1:${port}`; resolve(); }); }); } function close(target) { return new Promise((resolve) => { if (!target) return resolve(); target.close(() => resolve()); }); } async function get(path, { auth = `Bearer ${TOKEN}` } = {}) { const headers = {}; if (auth) headers.Authorization = auth; const res = await fetch(`${baseUrl}${path}`, { headers }); const body = await res.json().catch(() => ({})); return { status: res.status, body }; } beforeEach(async () => { await startLogtoStub(); process.env.HEALTH_TOKEN = TOKEN; process.env.LOGTO_HEALTH_URL = logtoUrl; await startServer(); }); afterEach(async () => { await close(server); await close(logtoServer); }); describe("metrics module", () => { test("exposes getCpuPercent, getDisk and collectMetrics", () => { expect(typeof getCpuPercent).toBe("function"); expect(typeof getDisk).toBe("function"); expect(typeof collectMetrics).toBe("function"); }); test("getDisk returns the four numeric disk fields", () => { const disk = getDisk(); expect(Object.keys(disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]); for (const value of Object.values(disk)) expect(typeof value).toBe("number"); }); test("collectMetrics returns the cpu/memory/disk slice of /health", async () => { const metrics = await collectMetrics(); expect(Object.keys(metrics)).toEqual(["cpu", "memory", "disk"]); expect(Object.keys(metrics.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]); expect(Object.keys(metrics.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]); expect(metrics.cpu.cores).toBeGreaterThan(0); expect(Array.isArray(metrics.cpu.loadAvg)).toBe(true); }); }); describe("GET /health", () => { test("401 on invalid token", async () => { const { status } = await get("/health", { auth: "Bearer wrong" }); expect(status).toBe(401); }); test("200 returns the documented payload, field for field", async () => { const { status, body } = await get("/health"); expect(status).toBe(200); // Key order is part of the contract consumed by the admin dashboard. expect(Object.keys(body)).toEqual([ "timestamp", "hostname", "uptime", "cpu", "memory", "disk", "logto", ]); expect(Object.keys(body.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]); expect(Object.keys(body.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]); expect(Object.keys(body.disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]); expect(body.logto.status).toBe("up"); expect(typeof body.logto.responseTimeMs).toBe("number"); expect(typeof body.uptime).toBe("number"); expect(typeof body.hostname).toBe("string"); expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp); }); // Latency regression guard. Metrics collection (500ms CPU sample) and the // Logto check must run concurrently: serializing them would make /health take // 500ms + the Logto response time. With a 1200ms Logto, concurrent lands at // ~1200ms while serialized lands at ~1700ms — only the former clears 1.5s. test("stays under 1.5s with a slow Logto (metrics stay concurrent)", async () => { logtoDelayMs = 1200; const start = performance.now(); const { status, body } = await get("/health"); const elapsed = performance.now() - start; expect(status).toBe(200); expect(body.logto.status).toBe("up"); expect(elapsed).toBeLessThan(1500); }, 15000); });