Files
proxdeploy/tests/server.test.ts
T
florian.wildeandClaude Opus 5.5 2b6381de41 feat(server): Standardwerte für Node, Storage und Bridge
Der Server kannte die Standards schon für kurze CLI-Befehle, aber nichts setzte sie. Jetzt im Server-Formular (Auswahl aus dem Verbindungstest) und als Vorbelegung im Assistenten.

Closes #023

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

214 lines
8.9 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("Standardwerte: gespeichert, leere Felder fallen weg, ganz leer ist null", async () => {
await liste.POST(anfrage("POST", "/api/v1/server", admin, eingabe()));
const setzen = async (standards: unknown) =>
await (
await einzeln.PATCH(
anfrage("PATCH", "/api/v1/server/rz1", admin, eingabe({ tokenGeheimnis: "", standards })),
kontext({ name: "rz1" }),
)
).json();
expect((await setzen({ knoten: "pve01", storage: " local-zfs ", bridge: "" })).standards).toEqual({
knoten: "pve01",
storage: "local-zfs",
});
expect((await testPrisma.server.findFirstOrThrow()).standards).toEqual({
knoten: "pve01",
storage: "local-zfs",
});
expect((await setzen({ knoten: "", storage: "", bridge: "" })).standards).toBeNull();
expect((await testPrisma.server.findFirstOrThrow()).standards).toBeNull();
});
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");
});
});