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>
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RateLimit } from "@/lib/ratelimit";
|
||||
import { erzeugeToken, hashGleich, hashToken, tokenAusHeader } from "@/lib/tokens";
|
||||
import { datumAusProxmox, statusAusProxmox } from "@/server/agent/schema";
|
||||
|
||||
describe("Tokens", () => {
|
||||
it("erzeugt eindeutige Tokens mit erkennbarem Präfix", () => {
|
||||
const a = erzeugeToken("host");
|
||||
const b = erzeugeToken("host");
|
||||
|
||||
expect(a.klartext.startsWith("plx_h_")).toBe(true);
|
||||
expect(erzeugeToken("enrollment").klartext.startsWith("plx_e_")).toBe(true);
|
||||
expect(a.klartext).not.toBe(b.klartext);
|
||||
expect(a.praefix.length).toBe("plx_h_".length + 6);
|
||||
});
|
||||
|
||||
it("hasht deterministisch und vergleicht ohne Zeitunterschiede", () => {
|
||||
const token = erzeugeToken("host");
|
||||
expect(hashToken(token.klartext)).toBe(token.hash);
|
||||
expect(hashToken(` ${token.klartext} `)).toBe(token.hash);
|
||||
expect(hashGleich(token.hash, token.hash)).toBe(true);
|
||||
expect(hashGleich(token.hash, hashToken("etwas anderes"))).toBe(false);
|
||||
});
|
||||
|
||||
it("liest den Bearer-Token aus dem Header", () => {
|
||||
expect(tokenAusHeader("Bearer plx_h_abc")).toBe("plx_h_abc");
|
||||
expect(tokenAusHeader("bearer plx_h_abc ")).toBe("plx_h_abc");
|
||||
expect(tokenAusHeader("Basic abc")).toBeNull();
|
||||
expect(tokenAusHeader(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Datums- und Statusumwandlung", () => {
|
||||
it("versteht die Formate von Proxmox", () => {
|
||||
expect(datumAusProxmox("2026-09-24")?.toISOString()).toBe("2026-09-24T00:00:00.000Z");
|
||||
expect(datumAusProxmox(1789610400)?.toISOString()).toBe("2026-09-17T02:00:00.000Z");
|
||||
expect(datumAusProxmox("1789610400")?.toISOString()).toBe("2026-09-17T02:00:00.000Z");
|
||||
expect(datumAusProxmox("2026-09-17T03:41:00Z")?.toISOString()).toBe("2026-09-17T03:41:00.000Z");
|
||||
});
|
||||
|
||||
it("liefert null statt eines Fehlers bei unbrauchbaren Werten", () => {
|
||||
expect(datumAusProxmox(null)).toBeNull();
|
||||
expect(datumAusProxmox("")).toBeNull();
|
||||
expect(datumAusProxmox("kein datum")).toBeNull();
|
||||
expect(datumAusProxmox(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("bildet Proxmox-Status ab und fällt sonst auf UNKNOWN zurück", () => {
|
||||
expect(statusAusProxmox("active")).toBe("ACTIVE");
|
||||
expect(statusAusProxmox("NotFound")).toBe("NOTFOUND");
|
||||
expect(statusAusProxmox("expired")).toBe("EXPIRED");
|
||||
expect(statusAusProxmox("etwas neues")).toBe("UNKNOWN");
|
||||
expect(statusAusProxmox(null)).toBe("UNKNOWN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rate-Limit", () => {
|
||||
it("lässt die Kapazität zu und bremst danach", () => {
|
||||
const limit = new RateLimit(3, 1);
|
||||
const jetzt = Date.now();
|
||||
|
||||
expect(limit.pruefe("a", jetzt).erlaubt).toBe(true);
|
||||
expect(limit.pruefe("a", jetzt).erlaubt).toBe(true);
|
||||
expect(limit.pruefe("a", jetzt).erlaubt).toBe(true);
|
||||
|
||||
const abgelehnt = limit.pruefe("a", jetzt);
|
||||
expect(abgelehnt.erlaubt).toBe(false);
|
||||
expect(abgelehnt.wiederholenNach).toBeGreaterThan(0);
|
||||
|
||||
// Anderer Schlüssel hat einen eigenen Eimer.
|
||||
expect(limit.pruefe("b", jetzt).erlaubt).toBe(true);
|
||||
// Nach einer Sekunde ist wieder ein Token verfügbar.
|
||||
expect(limit.pruefe("a", jetzt + 1000).erlaubt).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import "dotenv/config";
|
||||
|
||||
// Tests laufen immer gegen die Testdatenbank, nie gegen die Entwicklungsdatenbank.
|
||||
if (process.env.TEST_DATABASE_URL) {
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
}
|
||||
Reference in New Issue
Block a user