import { execFileSync } from "node:child_process"; import { createHash, X509Certificate } from "node:crypto"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { createServer, type Server } from "node:https"; import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; /** * Ein Proxmox-Ersatz für Tests: HTTPS mit selbst signiertem Zertifikat (wie Proxmox ab Werk), * Token-Prüfung und frei definierbaren Routen. Er zeichnet jeden Aufruf auf, damit Tests prüfen * können, was ProxDeploy geschickt hätte. * * Die Zustände einzelner PVE-Versionen (8.3, 8.4, 9.2) kommen mit #003 dazu. */ export type MockAnfrage = { methode: string; pfad: string; abfrage: URLSearchParams; rumpf: URLSearchParams; autorisierung: string | undefined; }; export type MockAntwort = { status?: number; statusText?: string; data?: unknown; errors?: Record; }; export type MockRoute = ( anfrage: MockAnfrage, treffer: RegExpExecArray, ) => MockAntwort | Promise; export const MOCK_TOKEN_ID = "proxdeploy@pve!test"; export const MOCK_TOKEN_GEHEIMNIS = "00000000-1111-2222-3333-444444444444"; let zertifikatCache: { schluessel: string; zertifikat: string; fingerabdruck: string } | undefined; /** Ein selbst signiertes Zertifikat per openssl – einmal je Testlauf. */ export function testZertifikat(): { schluessel: string; zertifikat: string; fingerabdruck: string } { if (zertifikatCache) return zertifikatCache; const ordner = mkdtempSync(join(tmpdir(), "proxdeploy-mock-")); try { execFileSync( "openssl", [ "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes", "-keyout", join(ordner, "key.pem"), "-out", join(ordner, "cert.pem"), "-days", "2", "-subj", "/CN=pve01.test/O=PVE Cluster Node", "-addext", "subjectAltName=DNS:pve01.test,IP:127.0.0.1", ], { stdio: "ignore" }, ); const zertifikat = readFileSync(join(ordner, "cert.pem"), "utf8"); const fingerabdruck = new X509Certificate(zertifikat).fingerprint256; zertifikatCache = { schluessel: readFileSync(join(ordner, "key.pem"), "utf8"), zertifikat, fingerabdruck, }; return zertifikatCache; } finally { rmSync(ordner, { recursive: true, force: true }); } } export class ProxmoxMock { readonly anfragen: MockAnfrage[] = []; private readonly routen: Array<{ methode: string; muster: RegExp; route: MockRoute }> = []; private server: Server | undefined; url = ""; fingerabdruck = ""; route(methode: string, muster: RegExp | string, route: MockRoute): this { const regex = typeof muster === "string" ? new RegExp(`^${muster.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`) : muster; this.routen.unshift({ methode, muster: regex, route }); return this; } async starten(port = 0): Promise { const { schluessel, zertifikat, fingerabdruck } = testZertifikat(); this.fingerabdruck = fingerabdruck; this.server = createServer({ key: schluessel, cert: zertifikat }, (req, res) => { const teile: Buffer[] = []; req.on("data", (teil: Buffer) => teile.push(teil)); req.on("end", async () => { const adresse = new URL(req.url ?? "/", "https://localhost"); const anfrage: MockAnfrage = { methode: req.method ?? "GET", pfad: decodeURIComponent(adresse.pathname.replace(/^\/api2\/json/, "")), abfrage: adresse.searchParams, rumpf: new URLSearchParams(Buffer.concat(teile).toString("utf8")), autorisierung: req.headers.authorization, }; this.anfragen.push(anfrage); const senden = (status: number, statusText: string, koerper: unknown) => { res.statusMessage = statusText; res.writeHead(status, { "content-type": "application/json;charset=UTF-8" }); res.end(JSON.stringify(koerper)); }; if (anfrage.autorisierung !== `PVEAPIToken=${MOCK_TOKEN_ID}=${MOCK_TOKEN_GEHEIMNIS}`) { senden(401, "authentication failure", { data: null }); return; } for (const { methode, muster, route } of this.routen) { const treffer = methode === anfrage.methode ? muster.exec(anfrage.pfad) : null; if (!treffer) continue; try { const antwort = await route(anfrage, treffer); senden(antwort.status ?? 200, antwort.statusText ?? "OK", { data: antwort.data ?? null, ...(antwort.errors ? { errors: antwort.errors } : {}), }); } catch (fehler) { senden(500, String(fehler), { data: null }); } return; } senden(501, `Method '${anfrage.methode} ${anfrage.pfad}' not implemented`, { data: null }); }); }); await new Promise((erfuellen) => this.server!.listen(port, "127.0.0.1", erfuellen)); this.url = `https://127.0.0.1:${(this.server.address() as AddressInfo).port}`; return this; } async stoppen(): Promise { await new Promise((erfuellen) => this.server?.close(() => erfuellen()) ?? erfuellen()); } /** Der Fingerprint eines **anderen** Zertifikats – für den Fall „Zertifikat ausgetauscht“. */ static fremderFingerabdruck(): string { const hex = createHash("sha256").update("anderes-zertifikat").digest("hex").toUpperCase(); return hex.match(/.{2}/g)!.join(":"); } }