import { createServer as createNetServer, type AddressInfo } from "node:net"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { aufgabeAbwarten, nodeAusUpid } from "@/engine/proxmox/aufgaben"; import { parameterKodieren, pfad, ProxmoxClient } from "@/engine/proxmox/client"; import { ProxmoxFehler, rechtHinweis } from "@/engine/proxmox/fehler"; import { fingerabdruckNormalisieren, zertifikatHolen } from "@/engine/proxmox/tls"; import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock"; const mock = new ProxmoxMock(); function client(ueberschreiben: Partial[0]> = {}) { return new ProxmoxClient({ url: mock.url, tokenId: MOCK_TOKEN_ID, tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS, tlsPruefen: false, fingerabdruck: mock.fingerabdruck, ...ueberschreiben, }); } async function fehlerVon(versprechen: Promise): Promise { try { await versprechen; } catch (fehler) { if (fehler instanceof ProxmoxFehler) return fehler; throw fehler; } throw new Error("Es wurde ein Fehler erwartet."); } beforeAll(async () => { await mock.starten(); mock .route("GET", "/version", () => ({ data: { version: "9.2.1", release: "9.2", repoid: "abc" } })) .route("GET", "/nodes/pve01/qemu/120/config", () => ({ status: 403, statusText: "Permission check failed (/vms/120, VM.Audit)", })) .route("POST", "/nodes/pve01/qemu", () => ({ status: 400, statusText: "Parameter verification failed.", errors: { memory: "value must have a minimum value of 16" }, })) .route("POST", "/echo", (anfrage) => ({ data: Object.fromEntries(anfrage.rumpf) })); }); afterAll(() => mock.stoppen()); describe("Fingerprint", () => { it("normalisiert übliche Schreibweisen", () => { const hex = "ab".repeat(32); const erwartet = "AB:".repeat(31) + "AB"; expect(fingerabdruckNormalisieren(hex)).toBe(erwartet); expect(fingerabdruckNormalisieren(`SHA256: ${erwartet.toLowerCase()}`)).toBe(erwartet); expect(fingerabdruckNormalisieren("zu-kurz")).toBeNull(); }); it("holt das Zertifikat des Servers mit Fingerprint und Namen", async () => { const info = await zertifikatHolen(mock.url); expect(info.fingerabdruck).toBe(mock.fingerabdruck); expect(info.subjekt).toContain("CN=pve01.test"); expect(info.vertraut).toBe(false); expect(info.alternativeNamen).toContain("DNS:pve01.test"); }); }); describe("ProxmoxClient", () => { it("spricht mit gepinntem Fingerprint und API-Token", async () => { expect(await client().get("/version")).toMatchObject({ version: "9.2.1" }); expect(mock.anfragen.at(-1)?.autorisierung).toBe(`PVEAPIToken=${MOCK_TOKEN_ID}=${MOCK_TOKEN_GEHEIMNIS}`); }); it("trennt bei geändertem Fingerprint, bevor das Token gesendet wird", async () => { const vorher = mock.anfragen.length; const fehler = await fehlerVon( client({ fingerabdruck: ProxmoxMock.fremderFingerabdruck() }).get("/version"), ); expect(fehler.art).toBe("fingerabdruck"); expect(fehler.hinweis).toMatch(/erneuert/); expect(mock.anfragen.length).toBe(vorher); }); it("meldet ein selbst signiertes Zertifikat bei TLS-Prüfung ohne Pin verständlich", async () => { const fehler = await fehlerVon(client({ tlsPruefen: true, fingerabdruck: null }).get("/version")); expect(fehler.art).toBe("tls"); expect(fehler.hinweis).toMatch(/Fingerprint holen/); }); it("verweigert eine Verbindung ganz ohne Prüfung", () => { expect(() => client({ tlsPruefen: false, fingerabdruck: null })).toThrow(/blind/); }); it("übersetzt 401 in einen Hinweis zum Token", async () => { const fehler = await fehlerVon(client({ tokenGeheimnis: "falsch" }).get("/version")); expect(fehler.art).toBe("anmeldung"); expect(fehler.hinweis).toMatch(/Token-ID/); }); it("nennt bei 403 das fehlende Recht und den Pfad", async () => { const fehler = await fehlerVon(client().get(pfad`/nodes/${"pve01"}/qemu/${120}/config`)); expect(fehler.art).toBe("recht"); expect(fehler.hinweis).toContain("VM.Audit auf /vms/120"); }); it("gibt abgelehnte Parameter einzeln zurück", async () => { const fehler = await fehlerVon(client().post("/nodes/pve01/qemu", { memory: 1 })); expect(fehler.art).toBe("parameter"); expect(fehler.ursache).toContain("memory: value must have a minimum value of 16"); }); it("meldet unbekannte Endpunkte als nicht unterstützt (alte PVE-Version)", async () => { const fehler = await fehlerVon(client().get("/gibt/es/nicht")); expect(fehler.art).toBe("nicht_gefunden"); }); it("meldet einen nicht erreichbaren Host", async () => { // Einen freien Port belegen und sofort wieder freigeben – dort lauscht garantiert niemand. const frei = await new Promise((erfuellen) => { const server = createNetServer().listen(0, "127.0.0.1", () => { const port = (server.address() as AddressInfo).port; server.close(() => erfuellen(port)); }); }); const fehler = await fehlerVon(client({ url: `https://127.0.0.1:${frei}` }).get("/version")); expect(fehler.art).toBe("verbindung"); expect(fehler.hinweis).toMatch(/8006/); }); it("kodiert Parameter wie pvesh: 1/0, Listen wiederholt, undefined weggelassen", async () => { expect(parameterKodieren({ a: true, b: false, c: ["x", "y"], d: undefined, e: 5 }).toString()).toBe( "a=1&b=0&c=x&c=y&e=5", ); expect(await client().post("/echo", { name: "web 01", full: true })).toEqual({ name: "web 01", full: "1", }); }); it("setzt Pfadteile kodiert ein", () => { expect(pfad`/nodes/${"pve 01"}/storage/${"a/b"}`).toBe("/nodes/pve%2001/storage/a%2Fb"); }); it("prüft das Format der Token-ID", () => { try { client({ tokenId: "root@pam" }); expect.unreachable(); } catch (fehler) { expect((fehler as ProxmoxFehler).hinweis).toMatch(/benutzer@realm!tokenname/); } }); it("meldet jeden Aufruf an einen Beobachter (für Log und Dry-Run)", async () => { const gesehen: string[] = []; const beobachtet = new ProxmoxClient( { url: mock.url, tokenId: MOCK_TOKEN_ID, tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS, tlsPruefen: false, fingerabdruck: mock.fingerabdruck, }, { beobachter: (aufruf, ergebnis) => gesehen.push(`${aufruf.methode} ${aufruf.pfad} ${ergebnis.fehler?.art ?? "ok"}`), }, ); await beobachtet.get("/version"); await beobachtet.get("/gibt/es/nicht").catch(() => undefined); expect(gesehen).toEqual(["GET /version ok", "GET /gibt/es/nicht nicht_gefunden"]); }); it("macht aus einem unbekannten Fehlertext einen allgemeinen Hinweis", () => { expect(rechtHinweis("irgendwas")).toMatch(/README/); }); }); describe("Proxmox-Tasks (UPID)", () => { const UPID = "UPID:pve01:0000A1B2:00C3D4E5:6710F00D:qmclone:9013:proxdeploy@pve!test:"; it("liest den Node aus der UPID", () => { expect(nodeAusUpid(UPID)).toBe("pve01"); expect(() => nodeAusUpid("kaputt")).toThrow(ProxmoxFehler); }); it("wartet, bis der Task fertig ist, und reicht Log-Zeilen weiter", async () => { let abfragen = 0; mock .route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/status$/, () => { abfragen += 1; return { data: abfragen < 3 ? { status: "running" } : { status: "stopped", exitstatus: "OK" } }; }) .route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/log$/, (anfrage) => { const start = Number(anfrage.abfrage.get("start")); return { data: start < abfragen ? [{ n: abfragen, t: `Zeile ${abfragen}` }] : [{ n: start, t: "no content" }], }; }); const zeilen: string[] = []; await aufgabeAbwarten(client(), UPID, { warten: async () => {}, beiLog: (neu) => zeilen.push(...neu) }); expect(abfragen).toBe(3); expect(zeilen).toEqual(["Zeile 1", "Zeile 2", "Zeile 3"]); }); it("wirft mit dem Exitstatus und den letzten Log-Zeilen, wenn der Task scheitert", async () => { mock .route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "unable to create VM 120 - already exists" }, })) .route("GET", /^\/nodes\/pve01\/tasks\/[^/]+\/log$/, () => ({ data: [{ n: 1, t: "TASK ERROR: already exists" }], })); const fehler = await fehlerVon(aufgabeAbwarten(client(), UPID, { warten: async () => {} })); expect(fehler.art).toBe("aufgabe"); expect(fehler.ursache).toContain("already exists"); expect(fehler.hinweis).toContain("TASK ERROR"); }); });