Files
proxdeploy/tests/server.test.ts
T
florian.wildeandClaude Opus 5.5 48e2a5cb8f feat(server): Proxmox-Server verwalten mit Fingerprint-Pinning
Server lassen sich anlegen, prüfen, bearbeiten und löschen. Proxmox
liefert ab Werk ein selbst signiertes Zertifikat; der Admin sieht es
vor dem Speichern und bestätigt den SHA-256-Fingerprint.

Der Pin wird in einem eigenen undici-Connector geprüft, sodass eine
fremde Gegenstelle das Token nie zu sehen bekommt. Weder Prüfung noch
Pin ist im Schema, im Client und per CHECK ausgeschlossen.

Fehler von Proxmox werden zu Ursache und Hinweis; bei 403 nennt der
Hinweis das fehlende Recht samt Pfad. Getestet gegen einen HTTPS-Mock
und im gebauten Stand auf Desktop und 390 px.

Closes #002

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:35:10 +02:00

193 lines
8.1 KiB
TypeScript

import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import * as einzeln from "@/app/api/v1/server/[name]/route";
import { POST as sshPost } from "@/app/api/v1/server/[name]/ssh/route";
import { POST as pruefenPost } from "@/app/api/v1/server/pruefen/route";
import * as liste from "@/app/api/v1/server/route";
import { entschluesseln } from "@/lib/geheimnisse";
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "./proxmox-mock/mock";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
const mock = new ProxmoxMock();
let admin: string;
let operator: string;
function eingabe(ueberschreiben: Record<string, unknown> = {}) {
return {
name: "rz1",
titel: "Rechenzentrum 1",
url: mock.url,
tokenId: MOCK_TOKEN_ID,
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
tlsPruefen: false,
fingerabdruck: mock.fingerabdruck,
...ueberschreiben,
};
}
describe.runIf(datenbankVerfuegbar)("Server-Verwaltung", () => {
beforeAll(async () => {
await mock.starten();
mock.route("GET", "/version", () => ({ data: { version: "9.2.1", release: "9.2" } }));
});
afterAll(() => mock.stoppen());
beforeEach(async () => {
await datenbankLeeren();
admin = (await angemeldet("ADMIN")).cookie;
operator = (await angemeldet("OPERATOR")).cookie;
});
it("Admin legt an; das Secret liegt verschlüsselt und kommt nie zurück", async () => {
const antwort = await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
expect(antwort.status).toBe(201);
const text = await antwort.text();
expect(text).not.toContain(MOCK_TOKEN_GEHEIMNIS);
expect(JSON.parse(text)).toMatchObject({ name: "rz1", fingerabdruck: mock.fingerabdruck });
const gespeichert = await testPrisma.server.findUniqueOrThrow({ where: { name: "rz1" } });
expect(gespeichert.tokenGeheimnis).toMatch(/^v1\./);
expect(entschluesseln(gespeichert.tokenGeheimnis)).toBe(MOCK_TOKEN_GEHEIMNIS);
const lesen = await liste.GET(anfrage("GET", "/api/v1/server", operator));
expect(await lesen.text()).not.toContain("v1.");
});
it("Operator darf lesen, aber nicht anlegen, ändern oder löschen", async () => {
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
expect(
(await liste.POST(anfrage("POST", "/api/v1/server", operator, eingabe({ name: "rz2" })))).status,
).toBe(403);
expect(
(
await einzeln.PATCH(
anfrage("PATCH", "/api/v1/server/rz1", operator, eingabe()),
kontext({ name: "rz1" }),
)
).status,
).toBe(403);
expect(
(await einzeln.DELETE(anfrage("DELETE", "/api/v1/server/rz1", operator), kontext({ name: "rz1" })))
.status,
).toBe(403);
expect(
(await einzeln.GET(anfrage("GET", "/api/v1/server/rz1", operator), kontext({ name: "rz1" }))).status,
).toBe(200);
});
it("ergänzt Schema und Port und normalisiert den Fingerprint", async () => {
const ohneSchema = mock.url.replace("https://", "");
const antwort = await liste.POST(
anfrage(
"POST",
"/api/v1/server",
admin,
eingabe({ url: ohneSchema, fingerabdruck: mock.fingerabdruck.replaceAll(":", "").toLowerCase() }),
),
);
expect(await antwort.json()).toMatchObject({ url: mock.url, fingerabdruck: mock.fingerabdruck });
const nurHost = await liste.POST(
anfrage("POST", "/api/v1/server", admin, eingabe({ name: "rz2", url: "pve02.example" })),
);
expect((await nurHost.json()).url).toBe("https://pve02.example:8006");
});
it("meldet ungültige Felder einzeln", async () => {
const antwort = await liste.POST(
anfrage(
"POST",
"/api/v1/server",
admin,
eingabe({ name: "RZ 1", tokenId: "root@pam", fingerabdruck: null }),
),
);
expect(antwort.status).toBe(400);
const { fehler } = await antwort.json();
expect(Object.keys(fehler.felder)).toEqual(expect.arrayContaining(["name", "tokenId", "fingerabdruck"]));
});
it("lehnt doppelte Namen ab", async () => {
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
expect((await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()))).status).toBe(409);
});
it("Ändern ohne Secret behält das alte; geänderte Felder stehen im Protokoll, Werte von Geheimnissen nicht", async () => {
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
const antwort = await einzeln.PATCH(
anfrage("PATCH", "/api/v1/server/rz1", admin, eingabe({ titel: "RZ eins", tokenGeheimnis: "" })),
kontext({ name: "rz1" }),
);
expect(antwort.status).toBe(200);
const server = await testPrisma.server.findUniqueOrThrow({ where: { name: "rz1" } });
expect(entschluesseln(server.tokenGeheimnis)).toBe(MOCK_TOKEN_GEHEIMNIS);
expect(server.titel).toBe("RZ eins");
const eintrag = await testPrisma.protokoll.findFirstOrThrow({ where: { aktion: "Server geändert" } });
expect(eintrag.details).toEqual({ felder: ["titel"] });
const alles = JSON.stringify(await testPrisma.protokoll.findMany(), (_, wert) =>
typeof wert === "bigint" ? wert.toString() : wert,
);
expect(alles).not.toContain(MOCK_TOKEN_GEHEIMNIS);
});
it("löscht und protokolliert", async () => {
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
expect(
(await einzeln.DELETE(anfrage("DELETE", "/api/v1/server/rz1", admin), kontext({ name: "rz1" }))).status,
).toBe(204);
expect(await testPrisma.server.count()).toBe(0);
expect(await testPrisma.protokoll.count({ where: { aktion: "Server gelöscht" } })).toBe(1);
expect(
(await einzeln.GET(anfrage("GET", "/api/v1/server/rz1", admin), kontext({ name: "rz1" }))).status,
).toBe(404);
});
it("Prüfen: liefert das Zertifikat zum Bestätigen, dann mit Pin die PVE-Version", async () => {
const ohnePin = await (
await pruefenPost(anfrage("POST", "/api/v1/server/pruefen", admin, { url: mock.url }))
).json();
expect(ohnePin.zertifikat.fingerabdruck).toBe(mock.fingerabdruck);
expect(ohnePin.zertifikat.vertraut).toBe(false);
expect(ohnePin.verbindung).toBeNull();
const mitPin = await (
await pruefenPost(anfrage("POST", "/api/v1/server/pruefen", admin, { ...eingabe(), name: undefined }))
).json();
expect(mitPin.pinPasst).toBe(true);
expect(mitPin.verbindung).toEqual({ ok: true, version: "9.2.1", release: "9.2" });
});
it("Prüfen: falsches Secret ergibt Ursache und Hinweis statt Fehlerseite", async () => {
const ergebnis = await (
await pruefenPost(
anfrage("POST", "/api/v1/server/pruefen", admin, eingabe({ tokenGeheimnis: "falsch" })),
)
).json();
expect(ergebnis.verbindung).toMatchObject({ ok: false, art: "anmeldung" });
});
it("Prüfen: bei bestehendem Server reicht der Name statt des Secrets", async () => {
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
const ergebnis = await (
await pruefenPost(anfrage("POST", "/api/v1/server/pruefen", admin, eingabe({ tokenGeheimnis: "" })))
).json();
expect(ergebnis.verbindung).toMatchObject({ ok: true });
});
it("Prüfen ist nur für Admins (baut Verbindungen zu beliebigen Adressen auf)", async () => {
expect(
(await pruefenPost(anfrage("POST", "/api/v1/server/pruefen", operator, { url: mock.url }))).status,
).toBe(403);
});
it("erzeugt ein SSH-Schlüsselpaar; nur der öffentliche Teil ist sichtbar", async () => {
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
const antwort = await sshPost(anfrage("POST", "/api/v1/server/rz1/ssh", admin), kontext({ name: "rz1" }));
const sicht = await antwort.json();
expect(sicht.sshOeffentlich).toMatch(/^ssh-ed25519 AAAA\S+ proxdeploy@rz1$/);
expect(JSON.stringify(sicht)).not.toContain("PRIVATE");
const server = await testPrisma.server.findUniqueOrThrow({ where: { name: "rz1" } });
expect(entschluesseln(server.sshPrivat!)).toContain("BEGIN OPENSSH PRIVATE KEY");
});
});