Files
ProxLedger/tests/agent-api.test.ts
T
florian.wildeandClaude Opus 5 bef51eb863 feat(api): Agent-API mit Enrollment, Report und Ping (#004)
Gehashte Tokens mit Praefix, Rate-Limit je IP und Token, zod-Validierung mit
durchgaengig optionalen Feldern, idempotente Reports ueber reportId, Key- und
Server-ID-Historie sowie automatischer Laufzeit-Vorschlag. 32 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:39:33 +02:00

372 lines
14 KiB
TypeScript

import { beforeEach, afterAll, describe, expect, it } from "vitest";
import { agentRateLimit, enrollRateLimit } from "@/lib/ratelimit";
import { erzeugeToken, hashToken } from "@/lib/tokens";
import { POST as enrollPost } from "@/app/api/agent/v1/enroll/route";
import { GET as pingGet } from "@/app/api/agent/v1/ping/route";
import { POST as reportPost } from "@/app/api/agent/v1/report/route";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/** Legt einen Enrollment-Token an und gibt den Klartext zurück. */
async function enrollmentTokenAnlegen(
optionen: { gueltigBis?: Date; maxNutzungen?: number; kundeId?: string; widerrufen?: boolean } = {},
) {
const token = erzeugeToken("enrollment");
await testPrisma.enrollmentToken.create({
data: {
tokenHash: token.hash,
praefix: token.praefix,
bezeichnung: "Test",
gueltigBis: optionen.gueltigBis ?? null,
maxNutzungen: optionen.maxNutzungen ?? null,
kundeId: optionen.kundeId ?? null,
widerrufenAm: optionen.widerrufen ? new Date() : null,
},
});
return token.klartext;
}
function anfrage(pfad: string, koerper: unknown, token?: string, methode = "POST") {
return new Request(`http://localhost:3000${pfad}`, {
method: methode,
headers: {
"content-type": "application/json",
...(token ? { authorization: `Bearer ${token}` } : {}),
"x-forwarded-for": `10.0.0.${Math.ceil(Math.random() * 250)}`,
},
body: methode === "GET" ? undefined : JSON.stringify(koerper),
});
}
const ENROLL_EINGABE = {
agentId: "machine-id-abcdef0123456789",
hostname: "pve-test-01",
fqdn: "pve-test-01.example.local",
produkt: "pve" as const,
serverId: "AABBCCDDEEFF0011",
agentVersion: "0.2.0",
};
/** Vollständiger Report, wie ihn der Agent auf einem PVE-Host erzeugt. */
function reportEingabe(überschreibungen: Record<string, unknown> = {}) {
return {
reportId: `report-${Math.random().toString(36).slice(2)}-0001`,
agentVersion: "0.2.0",
erhobenAm: "2026-09-17T03:41:00Z",
host: {
hostname: "pve-test-01",
fqdn: "pve-test-01.example.local",
ips: ["192.168.10.11", "10.20.0.11"],
clusterName: "test-cluster",
nodeName: "node01",
kernel: "6.8.12-4-pve",
uptimeSekunden: 4233600,
cpuModell: "Intel(R) Xeon(R) E-2388G",
sockets: 1,
paketUpdates: 3,
},
produkt: { typ: "pve" as const, version: "pve-manager/8.3.2" },
subscription: {
key: "pve1c-a6bd21fcb6",
status: "active",
level: "c",
productname: "Proxmox VE Community Subscription 1 CPU/year",
sockets: 1,
serverid: "AABBCCDDEEFF0011",
regdate: "2025-09-24",
nextduedate: "2026-09-24",
checktime: 1789610400,
},
...überschreibungen,
};
}
async function registriereTestHost() {
const enrollToken = await enrollmentTokenAnlegen();
const antwort = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken));
const daten = (await antwort.json()) as { hostId: string; token: string };
return daten;
}
describe.runIf(datenbankVerfuegbar)("Agent-API", () => {
beforeEach(async () => {
await datenbankLeeren();
agentRateLimit.zuruecksetzen();
enrollRateLimit.zuruecksetzen();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
describe("Registrierung", () => {
it("legt einen Host an und gibt genau einmal einen Token aus", async () => {
const enrollToken = await enrollmentTokenAnlegen({ maxNutzungen: 3 });
const antwort = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken));
const daten = (await antwort.json()) as { ok: boolean; token: string; hostId: string };
expect(antwort.status).toBe(201);
expect(daten.token.startsWith("plx_h_")).toBe(true);
const host = await testPrisma.host.findUnique({
where: { id: daten.hostId },
include: { tokens: true },
});
expect(host?.hostname).toBe("pve-test-01");
expect(host?.produkt).toBe("PVE");
// In der Datenbank darf nur der Hash liegen.
expect(host?.tokens[0].tokenHash).toBe(hashToken(daten.token));
expect(host?.tokens[0].tokenHash).not.toContain(daten.token);
const verbraucht = await testPrisma.enrollmentToken.findFirst();
expect(verbraucht?.nutzungen).toBe(1);
});
it("übernimmt die Kundenzuordnung aus dem Enrollment-Token", async () => {
const kunde = await testPrisma.kunde.create({
data: { name: "Zuordnung GmbH", kundennummer: "K-8001" },
});
const enrollToken = await enrollmentTokenAnlegen({ kundeId: kunde.id });
const antwort = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken));
const daten = (await antwort.json()) as { hostId: string };
const host = await testPrisma.host.findUnique({ where: { id: daten.hostId } });
expect(host?.kundeId).toBe(kunde.id);
});
it("erkennt einen bereits bekannten Agent und legt keinen zweiten Host an", async () => {
const enrollToken = await enrollmentTokenAnlegen({ maxNutzungen: 5 });
await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken));
const zweite = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken));
const daten = (await zweite.json()) as { bestehenderHost: boolean };
expect(zweite.status).toBe(200);
expect(daten.bestehenderHost).toBe(true);
expect(await testPrisma.host.count()).toBe(1);
});
it("meldet eine bereits bekannte Server-ID (Neuinstallation oder Klon)", async () => {
const enrollToken = await enrollmentTokenAnlegen({ maxNutzungen: 5 });
await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, enrollToken));
const antwort = await enrollPost(
anfrage(
"/api/agent/v1/enroll",
{ ...ENROLL_EINGABE, agentId: "machine-id-9999999999999999", hostname: "pve-neu-01" },
enrollToken,
),
);
const daten = (await antwort.json()) as { serverIdBereitsBekannt: boolean };
expect(daten.serverIdBereitsBekannt).toBe(true);
});
it.each([
["fehlendem Token", async () => undefined, 401],
["unbekanntem Token", async () => "plx_e_unbekannt", 401],
["widerrufenem Token", async () => enrollmentTokenAnlegen({ widerrufen: true }), 401],
[
"abgelaufenem Token",
async () => enrollmentTokenAnlegen({ gueltigBis: new Date("2020-01-01") }),
401,
],
["aufgebrauchtem Token", async () => enrollmentTokenAnlegen({ maxNutzungen: 0 }), 401],
])("weist Registrierung mit %s ab", async (_name, tokenErzeugen, status) => {
const token = await tokenErzeugen();
const antwort = await enrollPost(anfrage("/api/agent/v1/enroll", ENROLL_EINGABE, token));
expect(antwort.status).toBe(status);
expect(await testPrisma.host.count()).toBe(0);
});
it("lehnt unvollständige Eingaben mit 422 ab", async () => {
const enrollToken = await enrollmentTokenAnlegen();
const antwort = await enrollPost(
anfrage("/api/agent/v1/enroll", { hostname: "ohne-agent-id" }, enrollToken),
);
expect(antwort.status).toBe(422);
const daten = (await antwort.json()) as { code: string; felder: { feld: string }[] };
expect(daten.code).toBe("validierung");
expect(daten.felder.some((f) => f.feld === "agentId")).toBe(true);
});
});
describe("Report", () => {
it("übernimmt einen vollständigen Report und schlägt eine Laufzeit vor", async () => {
const { hostId, token } = await registriereTestHost();
const antwort = await reportPost(anfrage("/api/agent/v1/report", reportEingabe(), token));
const daten = (await antwort.json()) as { ok: boolean; laufzeitVorschlag: boolean };
expect(antwort.status).toBe(200);
expect(daten.laufzeitVorschlag).toBe(true);
const host = await testPrisma.host.findUniqueOrThrow({
where: { id: hostId },
include: { keyHistorie: true, serverIdHistorie: true, laufzeiten: true, reports: true },
});
expect(host.subStatus).toBe("ACTIVE");
expect(host.subKey).toBe("pve1c-a6bd21fcb6");
expect(host.subNextduedate?.toISOString().slice(0, 10)).toBe("2026-09-24");
expect(host.ips).toEqual(["192.168.10.11", "10.20.0.11"]);
expect(host.uptimeSekunden).toBe(BigInt(4233600));
expect(host.keyHistorie).toHaveLength(1);
expect(host.serverIdHistorie).toHaveLength(1);
expect(host.reports).toHaveLength(1);
const laufzeit = host.laufzeiten[0];
expect(laufzeit.status).toBe("VORSCHLAG");
expect(laufzeit.start.toISOString().slice(0, 10)).toBe("2026-09-24");
expect(laufzeit.ende.toISOString().slice(0, 10)).toBe("2027-09-23");
});
it("nimmt einen Report an, in dem alle optionalen Felder fehlen", async () => {
const { hostId, token } = await registriereTestHost();
const antwort = await reportPost(
anfrage(
"/api/agent/v1/report",
{ reportId: "nur-pflichtfelder-0001", erhobenAm: "2026-09-17T03:41:00Z" },
token,
),
);
expect(antwort.status).toBe(200);
const host = await testPrisma.host.findUniqueOrThrow({ where: { id: hostId } });
expect(host.subStatus).toBe("UNKNOWN");
expect(host.subKey).toBeNull();
expect(host.hostname).toBe("pve-test-01");
});
it("nimmt Reports mit null-Werten an und behandelt unbekannte Status als UNKNOWN", async () => {
const { hostId, token } = await registriereTestHost();
await reportPost(
anfrage(
"/api/agent/v1/report",
reportEingabe({
subscription: {
key: null,
status: "irgendwas-neues",
level: null,
productname: null,
sockets: null,
serverid: null,
regdate: null,
nextduedate: null,
checktime: null,
},
}),
token,
),
);
const host = await testPrisma.host.findUniqueOrThrow({ where: { id: hostId } });
expect(host.subStatus).toBe("UNKNOWN");
expect(host.subNextduedate).toBeNull();
});
it("verarbeitet denselben Report nur einmal", async () => {
const { hostId, token } = await registriereTestHost();
const eingabe = reportEingabe();
await reportPost(anfrage("/api/agent/v1/report", eingabe, token));
const zweite = await reportPost(anfrage("/api/agent/v1/report", eingabe, token));
const daten = (await zweite.json()) as { doppelt: boolean };
expect(daten.doppelt).toBe(true);
expect(await testPrisma.report.count({ where: { hostId } })).toBe(1);
expect(await testPrisma.laufzeit.count({ where: { hostId } })).toBe(1);
});
it("lässt einen nachgesendeten älteren Report den aktuellen Stand nicht überschreiben", async () => {
const { hostId, token } = await registriereTestHost();
await reportPost(anfrage("/api/agent/v1/report", reportEingabe(), token));
await reportPost(
anfrage(
"/api/agent/v1/report",
reportEingabe({
reportId: "alter-report-0001",
erhobenAm: "2026-09-10T03:41:00Z",
subscription: { ...reportEingabe().subscription, status: "expired" },
}),
token,
),
);
const host = await testPrisma.host.findUniqueOrThrow({ where: { id: hostId } });
expect(host.subStatus).toBe("ACTIVE");
expect(await testPrisma.report.count({ where: { hostId } })).toBe(2);
});
it("merkt einen Key-Wechsel in der Historie vor", async () => {
const { hostId, token } = await registriereTestHost();
await reportPost(anfrage("/api/agent/v1/report", reportEingabe(), token));
await reportPost(
anfrage(
"/api/agent/v1/report",
reportEingabe({
reportId: "neuer-key-0001",
erhobenAm: "2026-09-18T03:41:00Z",
subscription: { ...reportEingabe().subscription, key: "pve1c-neuerkey99" },
}),
token,
),
);
const historie = await testPrisma.subscriptionKeyHistorie.findMany({ where: { hostId } });
expect(historie.map((e) => e.key).sort()).toEqual(["pve1c-a6bd21fcb6", "pve1c-neuerkey99"]);
});
it("weist einen widerrufenen Host-Token ab", async () => {
const { token } = await registriereTestHost();
await testPrisma.agentToken.updateMany({ data: { widerrufenAm: new Date() } });
const antwort = await reportPost(anfrage("/api/agent/v1/report", reportEingabe(), token));
expect(antwort.status).toBe(401);
expect(await testPrisma.report.count()).toBe(0);
});
it("weist einen Report ohne Token ab", async () => {
const antwort = await reportPost(anfrage("/api/agent/v1/report", reportEingabe()));
expect(antwort.status).toBe(401);
});
it("lehnt einen Report ohne reportId mit 422 ab", async () => {
const { token } = await registriereTestHost();
const antwort = await reportPost(
anfrage("/api/agent/v1/report", { erhobenAm: "2026-09-17T03:41:00Z" }, token),
);
expect(antwort.status).toBe(422);
});
});
describe("Ping und Rate-Limit", () => {
it("bestätigt einen gültigen Host-Token", async () => {
const { token, hostId } = await registriereTestHost();
const antwort = await pingGet(anfrage("/api/agent/v1/ping", null, token, "GET"));
const daten = (await antwort.json()) as { ok: boolean; hostId: string; hostname: string };
expect(daten.ok).toBe(true);
expect(daten.hostId).toBe(hostId);
expect(daten.hostname).toBe("pve-test-01");
});
it("bremst zu viele Reports mit 429 und Retry-After", async () => {
const { token } = await registriereTestHost();
let letzte: Response | undefined;
for (let i = 0; i < 12; i++) {
letzte = await reportPost(
anfrage("/api/agent/v1/report", reportEingabe({ reportId: `flut-${i}-0001` }), token),
);
}
expect(letzte?.status).toBe(429);
expect(Number(letzte?.headers.get("Retry-After"))).toBeGreaterThan(0);
});
});
});