mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Pre-existing format drift accumulated across v1.4.x; running prettier brings the whole tree back to baseline so future diffs read clean. No behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
299 lines
8.9 KiB
TypeScript
299 lines
8.9 KiB
TypeScript
/**
|
|
* Integration test for `GET /api/admin/host-metrics` (v1.4.16 phase B3).
|
|
*
|
|
* Asserts:
|
|
* - requireAdmin gating (403 for non-admin, 401 anonymous)
|
|
* - default 2h window filters out older rows
|
|
* - bytes-per-second derivation for disk counters across consecutive
|
|
* samples (incl. negative-delta resets and null disk fields)
|
|
* - response shape (samples + meta)
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
async function seedAdminSession() {
|
|
const prisma = getPrismaClient();
|
|
const admin = await prisma.user.create({
|
|
data: {
|
|
username: "host-metrics-admin",
|
|
email: "host-metrics-admin@example.test",
|
|
role: "ADMIN",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: admin.id,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return admin;
|
|
}
|
|
|
|
async function seedNonAdminSession() {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "host-metrics-user",
|
|
email: "host-metrics-user@example.test",
|
|
role: "USER",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: user.id,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return user;
|
|
}
|
|
|
|
async function callRoute(query = "") {
|
|
const { GET } = await import("@/app/api/admin/host-metrics/route");
|
|
const url = `http://localhost/api/admin/host-metrics${query ? `?${query}` : ""}`;
|
|
return GET(new Request(url) as unknown as Parameters<typeof GET>[0]);
|
|
}
|
|
|
|
describe("GET /api/admin/host-metrics", () => {
|
|
it("rejects anonymous requests with 401", async () => {
|
|
const res = await callRoute();
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("rejects non-admin sessions with 403", async () => {
|
|
await seedNonAdminSession();
|
|
const res = await callRoute();
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("returns rows within the default 2h window, ordered ascending", async () => {
|
|
await seedAdminSession();
|
|
const prisma = getPrismaClient();
|
|
|
|
const now = Date.now();
|
|
// Three rows: 5h ago (filtered out), 90m ago (kept), 30m ago (kept).
|
|
await prisma.hostMetric.createMany({
|
|
data: [
|
|
{
|
|
capturedAt: new Date(now - 5 * 60 * 60_000),
|
|
loadAvg1: 0.1,
|
|
loadAvg5: 0.1,
|
|
loadAvg15: 0.1,
|
|
memUsedBytes: BigInt(1_000_000),
|
|
memTotalBytes: BigInt(8_000_000),
|
|
diskReadBytes: null,
|
|
diskWriteBytes: null,
|
|
},
|
|
{
|
|
capturedAt: new Date(now - 90 * 60_000),
|
|
loadAvg1: 0.5,
|
|
loadAvg5: 0.4,
|
|
loadAvg15: 0.3,
|
|
memUsedBytes: BigInt(2_000_000),
|
|
memTotalBytes: BigInt(8_000_000),
|
|
diskReadBytes: null,
|
|
diskWriteBytes: null,
|
|
},
|
|
{
|
|
capturedAt: new Date(now - 30 * 60_000),
|
|
loadAvg1: 0.7,
|
|
loadAvg5: 0.5,
|
|
loadAvg15: 0.4,
|
|
memUsedBytes: BigInt(4_000_000),
|
|
memTotalBytes: BigInt(8_000_000),
|
|
diskReadBytes: null,
|
|
diskWriteBytes: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
const res = await callRoute();
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: {
|
|
samples: Array<{
|
|
capturedAt: string;
|
|
loadAvg1: number;
|
|
memUsedPercent: number;
|
|
diskReadBps: number | null;
|
|
diskWriteBps: number | null;
|
|
}>;
|
|
meta: { since: string; count: number; memTotalBytes: number };
|
|
};
|
|
};
|
|
|
|
expect(body.data.samples).toHaveLength(2);
|
|
expect(body.data.samples[0].loadAvg1).toBe(0.5);
|
|
expect(body.data.samples[1].loadAvg1).toBe(0.7);
|
|
// 4_000_000 / 8_000_000 = 50%
|
|
expect(body.data.samples[1].memUsedPercent).toBe(50);
|
|
expect(body.data.meta.since).toBe("2h");
|
|
expect(body.data.meta.count).toBe(2);
|
|
expect(body.data.meta.memTotalBytes).toBe(8_000_000);
|
|
});
|
|
|
|
it("derives diskReadBps/diskWriteBps from cumulative byte counters", async () => {
|
|
await seedAdminSession();
|
|
const prisma = getPrismaClient();
|
|
|
|
const t0 = new Date(Date.now() - 60 * 60_000); // 1h ago
|
|
const t1 = new Date(t0.getTime() + 60_000); // 59m ago
|
|
const t2 = new Date(t1.getTime() + 60_000); // 58m ago
|
|
|
|
await prisma.hostMetric.createMany({
|
|
data: [
|
|
{
|
|
capturedAt: t0,
|
|
loadAvg1: 0,
|
|
loadAvg5: 0,
|
|
loadAvg15: 0,
|
|
memUsedBytes: BigInt(1),
|
|
memTotalBytes: BigInt(2),
|
|
// 1 GiB read, 0.5 GiB written cumulatively at t0.
|
|
diskReadBytes: BigInt(1_073_741_824),
|
|
diskWriteBytes: BigInt(536_870_912),
|
|
},
|
|
{
|
|
capturedAt: t1,
|
|
loadAvg1: 0,
|
|
loadAvg5: 0,
|
|
loadAvg15: 0,
|
|
memUsedBytes: BigInt(1),
|
|
memTotalBytes: BigInt(2),
|
|
// +60 MiB read, +30 MiB written in 60s → 1 MiB/s and 0.5 MiB/s.
|
|
diskReadBytes: BigInt(1_073_741_824 + 60 * 1_048_576),
|
|
diskWriteBytes: BigInt(536_870_912 + 30 * 1_048_576),
|
|
},
|
|
{
|
|
capturedAt: t2,
|
|
loadAvg1: 0,
|
|
loadAvg5: 0,
|
|
loadAvg15: 0,
|
|
memUsedBytes: BigInt(1),
|
|
memTotalBytes: BigInt(2),
|
|
// Counter reset (e.g. host reboot) → bps must be null, not negative.
|
|
diskReadBytes: BigInt(0),
|
|
diskWriteBytes: BigInt(0),
|
|
},
|
|
],
|
|
});
|
|
|
|
const res = await callRoute();
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: {
|
|
samples: Array<{
|
|
diskReadBps: number | null;
|
|
diskWriteBps: number | null;
|
|
}>;
|
|
};
|
|
};
|
|
|
|
expect(body.data.samples).toHaveLength(3);
|
|
// First row has no predecessor — disk fields are null.
|
|
expect(body.data.samples[0].diskReadBps).toBeNull();
|
|
expect(body.data.samples[0].diskWriteBps).toBeNull();
|
|
// Second row: 60 MiB / 60s = 1 MiB/s ≈ 1_048_576 B/s.
|
|
expect(body.data.samples[1].diskReadBps).toBe(1_048_576);
|
|
expect(body.data.samples[1].diskWriteBps).toBe(524_288);
|
|
// Third row: counter reset → both fields null.
|
|
expect(body.data.samples[2].diskReadBps).toBeNull();
|
|
expect(body.data.samples[2].diskWriteBps).toBeNull();
|
|
});
|
|
|
|
it("respects the ?since= preset", async () => {
|
|
await seedAdminSession();
|
|
const prisma = getPrismaClient();
|
|
const now = Date.now();
|
|
|
|
await prisma.hostMetric.createMany({
|
|
data: [
|
|
{
|
|
capturedAt: new Date(now - 90 * 60_000), // 90m ago — outside 30m
|
|
loadAvg1: 0.5,
|
|
loadAvg5: 0.5,
|
|
loadAvg15: 0.5,
|
|
memUsedBytes: BigInt(1),
|
|
memTotalBytes: BigInt(2),
|
|
diskReadBytes: null,
|
|
diskWriteBytes: null,
|
|
},
|
|
{
|
|
capturedAt: new Date(now - 10 * 60_000), // 10m ago — inside 30m
|
|
loadAvg1: 0.7,
|
|
loadAvg5: 0.5,
|
|
loadAvg15: 0.5,
|
|
memUsedBytes: BigInt(1),
|
|
memTotalBytes: BigInt(2),
|
|
diskReadBytes: null,
|
|
diskWriteBytes: null,
|
|
},
|
|
],
|
|
});
|
|
|
|
const res = await callRoute("since=30m");
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: {
|
|
samples: Array<{ loadAvg1: number }>;
|
|
meta: { since: string; count: number };
|
|
};
|
|
};
|
|
expect(body.data.meta.since).toBe("30m");
|
|
expect(body.data.samples).toHaveLength(1);
|
|
expect(body.data.samples[0].loadAvg1).toBe(0.7);
|
|
});
|
|
|
|
it("returns 200 with an empty list when no samples have been written yet", async () => {
|
|
await seedAdminSession();
|
|
const res = await callRoute();
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: {
|
|
samples: unknown[];
|
|
meta: { since: string; count: number; memTotalBytes: number };
|
|
};
|
|
};
|
|
expect(body.data.samples).toEqual([]);
|
|
expect(body.data.meta.count).toBe(0);
|
|
expect(body.data.meta.memTotalBytes).toBe(0);
|
|
});
|
|
});
|