feat(schluessel): erzeugen, importieren, exportieren (#012)

Schluessel werden jetzt verwaltet statt nur im Prototyp gezeigt: Liste
mit Filter, Anlegen (erzeugen oder importieren), Detail nach dem
Prototyp aus #002, Bearbeiten der Metadaten und Download <slug>.pub.

Warum so:
- Erzeugen und Import eines Paars laufen als Vorgang im Worker, weil
  nur er den Master-Key hat. Passphrase und importierter Private Key
  reisen wie das Bootstrap-Passwort mit SSHKV_APP_SECRET verschluesselt
  im Job, nie in Vorgang.eingabe; der Worker loescht den Job danach.
- Ein reiner Public Key ist kein Geheimnis und wird direkt im Web
  angelegt, mit verstaendlicher Meldung bei einem Duplikat.
- Das Web liest nie das Chiffrat: ob ein Private Key gespeichert ist,
  verraet masterKeyVersion (per CHECK an das Chiffrat gekoppelt). Ein
  struktureller Test verlangt fuer jede zeilenliefernde Abfrage auf
  Schluessel ausserhalb des Workers ein festes select.
- Migration: Vorgangsarten fuer Erzeugen, Import und (fuer #014) das
  Loeschen des Private Keys; CHECKs fuer den Slug (landet in
  Dateinamen) und "geloescht nur ohne Chiffrat".
- Platzhalter PublicAusrollenAktion/PrivateAusrollenAktion fuer #016/#017.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:29:52 +02:00
co-authored by Claude Opus 5.5
parent f0833aa1db
commit 263143134f
33 changed files with 3322 additions and 287 deletions
+74
View File
@@ -0,0 +1,74 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
/**
* Schluessel im gebauten Stand mit laufendem Worker (#012–#014). Testdaten heissen zz-e2e-…
* und bleiben liegen (Schluessel werden nicht geloescht, nur spaeter widerrufen).
*/
test.setTimeout(90_000);
async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader") {
await page.goto("/anmelden");
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
await page.locator(`button[data-rolle="${rolle}"]`).click();
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
}
function eindeutig(): string {
return `zz-e2e-${Date.now().toString(36)}`;
}
test("Operator erzeugt einen Schlüssel und landet im Detail", async ({ page }) => {
const name = `${eindeutig()} Backup`;
await oidcAnmelden(page, "operator");
await page.goto("/schluessel/neu");
await page.getByLabel("Zweck").fill(name);
await page.getByLabel("Tags").fill("e2e");
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
await expect(page.getByRole("heading", { name })).toBeVisible({ timeout: 30_000 });
await expect(page).toHaveURL(/\/schluessel\/[^/]+$/);
await expect(page.getByText("verschlüsselt gespeichert")).toBeVisible();
await expect(page.getByText("ed25519", { exact: true })).toBeVisible();
await expect(page.getByLabel("Public Key", { exact: true })).toContainText("ssh-ed25519 AAAA");
await expect(page.getByLabel("Public Key", { exact: true })).toContainText(
`sshkv:${name.toLowerCase().replace(/ /g, "-")}`,
);
await page.goto(`/schluessel?q=${encodeURIComponent(name)}`);
await expect(page.getByRole("link", { name })).toBeVisible();
});
test("Public Key importieren, Duplikat wird erkannt", async ({ page }) => {
const name = `${eindeutig()} Laptop`;
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "e2e@laptop" });
await oidcAnmelden(page, "operator");
await page.goto("/schluessel/neu");
await page.getByRole("radio", { name: "Importieren" }).click();
await page.getByLabel("Zweck").fill(name);
await page.getByLabel("Public Key").fill(paar.publicKeyZeile);
await page.locator(`input[name="kategorie"][value="PERSON"]`).check();
await page.getByRole("button", { name: "Schlüssel importieren" }).click();
await expect(page.getByRole("heading", { name })).toBeVisible();
await expect(page.getByText("nicht gespeichert (Personen-Key)")).toBeVisible();
await expect(page.getByText(paar.fingerprint)).toBeVisible();
await page.goto("/schluessel/neu");
await page.getByRole("radio", { name: "Importieren" }).click();
await page.getByLabel("Zweck").fill(`${name} nochmal`);
await page.getByLabel("Public Key").fill(paar.publicKeyZeile);
await page.getByRole("button", { name: "Schlüssel importieren" }).click();
await expect(page.getByRole("alert").first()).toContainText(`bereits verwaltet: „${name}“`);
});
test("Leser sieht Schlüssel, darf aber keine anlegen", async ({ page }) => {
await oidcAnmelden(page, "reader");
await page.goto("/schluessel");
await expect(page.getByRole("heading", { name: "Schlüssel", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Schlüssel anlegen" })).toHaveCount(0);
await page.goto("/schluessel/neu");
await expect(page).toHaveURL(/\/kein-zugriff/);
});