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>
This commit is contained in:
2026-10-12 09:35:10 +02:00
co-authored by Claude Opus 5.5
parent 871bb3a77c
commit 48e2a5cb8f
43 changed files with 2847 additions and 39 deletions
+16
View File
@@ -0,0 +1,16 @@
import { ProxmoxMock } from "../proxmox-mock/mock";
/**
* Startet für die Abläufe einen Proxmox-Mock auf festem Port. Die gebaute Anwendung verbindet
* sich dorthin wie zu einem echten Host – mit selbst signiertem Zertifikat.
*/
export const MOCK_PORT = 18006;
export default async function aufbau(): Promise<() => Promise<void>> {
const mock = new ProxmoxMock();
mock.route("GET", "/version", () => ({ data: { version: "9.2.1", release: "9.2" } }));
await mock.starten(MOCK_PORT);
return async () => {
await mock.stoppen();
};
}
+4 -18
View File
@@ -1,25 +1,11 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { anmelden as anmeldenZu, keinQuerscrollen } from "./helfer";
const BENUTZER = process.env["E2E_BENUTZER"] ?? "admin";
const PASSWORT = process.env["E2E_PASSWORT"] ?? "proxdeploy-dev-admin";
async function anmelden(page: Page) {
await page.goto("/");
await expect(page).toHaveURL(/\/anmelden/);
await page.getByLabel("Benutzername").fill(BENUTZER);
await page.getByLabel("Passwort").fill(PASSWORT);
await page.getByRole("button", { name: "Anmelden" }).click();
async function anmelden(page: import("@playwright/test").Page) {
await anmeldenZu(page);
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
}
async function keinQuerscrollen(page: Page) {
const breiten = await page.evaluate(() => ({
inhalt: document.documentElement.scrollWidth,
fenster: window.innerWidth,
}));
expect(breiten.inhalt).toBeLessThanOrEqual(breiten.fenster);
}
test("falsches Passwort zeigt eine Meldung mit Symbol und Text", async ({ page }) => {
await page.goto("/anmelden");
// Eigener Name: Fehlversuche zählen je Benutzer, der echte Admin soll nie gesperrt werden.
+24
View File
@@ -0,0 +1,24 @@
import { expect, type Page } from "@playwright/test";
export const BENUTZER = process.env["E2E_BENUTZER"] ?? "admin";
export const PASSWORT = process.env["E2E_PASSWORT"] ?? "proxdeploy-dev-admin";
export async function anmelden(page: Page, ziel = "/") {
await page.goto(ziel);
await expect(page).toHaveURL(/\/anmelden/);
await page.getByLabel("Benutzername").fill(BENUTZER);
await page.getByLabel("Passwort").fill(PASSWORT);
await page.getByRole("button", { name: "Anmelden" }).click();
await expect(page).not.toHaveURL(/\/anmelden/);
}
export async function keinQuerscrollen(page: Page) {
const breiten = await page.evaluate(() => ({
inhalt: document.documentElement.scrollWidth,
fenster: window.innerWidth,
}));
expect(breiten.inhalt).toBeLessThanOrEqual(breiten.fenster);
}
/** Mit dem Präfix zz-e2e lassen sich Testdaten erkennen und aufräumen. */
export const E2E_PRAEFIX = "zz-e2e";
+43
View File
@@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test";
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID } from "../proxmox-mock/mock";
import { MOCK_PORT } from "./global-setup";
import { anmelden, E2E_PRAEFIX, keinQuerscrollen } from "./helfer";
test("Server anlegen: Zertifikat pinnen, Verbindung prüfen, SSH-Schlüssel, löschen", async ({ page }) => {
const name = `${E2E_PRAEFIX}-${test.info().project.name}`;
await anmelden(page, "/server/neu");
await expect(page.getByRole("heading", { name: "Server anlegen" })).toBeVisible();
await keinQuerscrollen(page);
await page.getByLabel("Kurzname").fill(name);
await page.getByLabel("Anzeigename").fill("E2E-Testserver");
await page.getByLabel("Adresse").fill(`127.0.0.1:${MOCK_PORT}`);
await page.getByRole("button", { name: "Zertifikat holen" }).click();
await expect(page.getByText("Selbst signiertes Zertifikat")).toBeVisible();
await page.getByLabel("Fingerprint stimmt – Zertifikat pinnen").check();
await page.getByLabel("Token-ID").fill(MOCK_TOKEN_ID);
await page.getByLabel("Secret").fill(MOCK_TOKEN_GEHEIMNIS);
await page.getByRole("button", { name: "Verbindung prüfen" }).click();
await expect(page.getByText("Verbunden · Proxmox VE")).toBeVisible();
await expect(page.getByText("9.2.1")).toBeVisible();
await page.screenshot({ path: `test-results/server-neu-${test.info().project.name}.png`, fullPage: true });
await page.getByLabel("SSH zu den Nodes nutzen").check();
await page.getByRole("button", { name: "Server anlegen" }).click();
await expect(page).toHaveURL(new RegExp(`/server/${name}$`));
await expect(page.getByRole("heading", { name: "E2E-Testserver" })).toBeVisible();
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
await expect(page.getByText(/ssh-ed25519 AAAA/)).toBeVisible();
await keinQuerscrollen(page);
await page.screenshot({
path: `test-results/server-detail-${test.info().project.name}.png`,
fullPage: true,
});
await page.getByRole("button", { name: "Server löschen" }).click();
await page.getByRole("button", { name: "Wirklich löschen" }).click();
await expect(page).toHaveURL(/\/server$/);
await expect(page.getByText(name)).toHaveCount(0);
});
+218
View File
@@ -0,0 +1,218 @@
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<ConstructorParameters<typeof ProxmoxClient>[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<unknown>): Promise<ProxmoxFehler> {
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<number>((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");
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { Rolle } from "@/generated/prisma/enums";
import { SITZUNG_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { testPrisma } from "./db";
/** Legt einen Benutzer an und liefert ein passendes Sitzungscookie. */
export async function angemeldet(rolle: Rolle, benutzername = rolle.toLowerCase()) {
const benutzer = await testPrisma.benutzer.create({
data: { benutzername, anzeigename: benutzername, rolle },
});
const cookie = `${SITZUNG_COOKIE}=${await sitzungErzeugen({ benutzerId: benutzer.id, herkunft: "passwort" })}`;
return { benutzer, cookie };
}
/** Baut eine Anfrage an die API mit Cookie und optional JSON-Rumpf. */
export function anfrage(methode: string, pfad: string, cookie: string | null, rumpf?: unknown): Request {
return new Request(`http://localhost${pfad}`, {
method: methode,
headers: {
...(cookie ? { cookie } : {}),
...(rumpf !== undefined ? { "content-type": "application/json" } : {}),
},
body: rumpf !== undefined ? JSON.stringify(rumpf) : undefined,
});
}
export const kontext = <T extends Record<string, string>>(params: T) => ({ params: Promise.resolve(params) });
+153
View File
@@ -0,0 +1,153 @@
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<string, string>;
};
export type MockRoute = (
anfrage: MockAnfrage,
treffer: RegExpExecArray,
) => MockAntwort | Promise<MockAntwort>;
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<this> {
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<void>((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<void> {
await new Promise<void>((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(":");
}
}
+192
View File
@@ -0,0 +1,192 @@
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");
});
});