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);
});