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 = {}) { 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"); }); });