import { expect, test, type Page } from "@playwright/test"; import pg from "pg"; 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(); } async function datenbank(arbeit: (client: pg.Client) => Promise): Promise { const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] }); await client.connect(); try { return await arbeit(client); } finally { await client.end(); } } 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/); }); test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async ({ page }) => { const name = `${eindeutig()} Laptop`; const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "reader@laptop" }); await oidcAnmelden(page, "reader"); await page.getByRole("link", { name: "Meine Schlüssel" }).first().click(); await expect(page.getByRole("heading", { name: "Meine Schlüssel" })).toBeVisible(); await expect(page.getByText(/Wirksam erst, wenn ein Operator oder Admin/).first()).toBeVisible(); await page.getByLabel("Bezeichnung").fill(name); await page.getByLabel("Public Key").fill(paar.publicKeyZeile); await page.getByRole("button", { name: "Hinterlegen" }).click(); await expect(page.getByText(`Hinterlegt: ${paar.fingerprint}`)).toBeVisible(); const eintrag = page.locator("li", { hasText: name }); await expect(eintrag.getByText("noch nicht zugewiesen")).toBeVisible(); await eintrag.getByRole("button", { name: `${name} entfernen` }).click(); await page.getByRole("dialog").getByRole("button", { name: "Entfernen", exact: true }).click(); await expect(page.locator("li", { hasText: name })).toHaveCount(0); }); /** * Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem * Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab. */ async function oidcBestaetigen(page: Page) { await expect(page).toHaveURL(/\/bestaetigen\?ziel=/); await page.getByRole("link", { name: /erneut anmelden/ }).click(); await page.locator('button[data-rolle="admin"]').click(); const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record; await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) })); await page.getByRole("button", { name: "Anmelden", exact: true }).click(); } test("Admin zeigt den Private Key nach Bestätigung an, lädt ihn herunter und löscht ihn", async ({ page, }) => { const name = `${eindeutig()} Deploy`; await oidcAnmelden(page, "admin"); // Eine Bestaetigung aus einem frueheren Lauf soll nicht mitzaehlen. await datenbank((client) => client.query(`UPDATE "Benutzer" SET "bestaetigtAm" = NULL WHERE "email" = 'admin@sshkv.test'`), ); await page.goto("/schluessel/neu"); await page.getByLabel("Zweck").fill(name); await page.getByRole("button", { name: "Schlüssel erzeugen" }).click(); await expect(page.getByRole("heading", { name })).toBeVisible({ timeout: 30_000 }); const detail = page.url(); const fingerprint = ( await page .getByText(/^SHA256:/) .first() .textContent() )?.trim() ?? ""; // Ohne frische Bestaetigung geht es erst nach /bestaetigen und von dort zurueck in den Dialog. await page.getByRole("button", { name: "anzeigen (Re-Auth)" }).click(); const dialog = page.getByRole("dialog"); await dialog.getByRole("button", { name: "Anzeigen" }).click(); await oidcBestaetigen(page); await expect(page).toHaveURL(/privatekey=1/); await dialog.getByRole("button", { name: "Anzeigen" }).click(); const block = dialog.getByLabel("Private Key", { exact: true }); await expect(block).toContainText("-----BEGIN OPENSSH PRIVATE KEY-----", { timeout: 30_000 }); await expect(dialog.getByText("Wird nicht erneut angezeigt.")).toBeVisible(); await dialog.getByRole("button", { name: "Schließen" }).click(); await expect(block).toHaveCount(0); // Download: eigene Abholung, Datei , ohne Caching. await page.getByRole("button", { name: "anzeigen (Re-Auth)" }).click(); const antwort = page.waitForResponse((r) => r.url().includes("/api/private-key/")); const datei = page.waitForEvent("download"); await dialog.getByRole("button", { name: "Herunterladen" }).click(); const kopf = (await antwort).headers(); expect(kopf["cache-control"]).toContain("no-store"); expect(kopf["content-disposition"]).toMatch(/^attachment; filename="zz-e2e-[a-z0-9]+-deploy"$/); const download = await datei; expect(download.suggestedFilename()).toMatch(/^zz-e2e-[a-z0-9]+-deploy$/); await expect(dialog.getByText(/Download gestartet/)).toBeVisible(); await dialog.getByRole("button", { name: "Schließen" }).click(); // Operator sieht keinen Anzeigen-Knopf und kein Loeschen. const operator = await page.context().browser()!.newContext(); const operatorSeite = await operator.newPage(); await oidcAnmelden(operatorSeite, "operator"); await operatorSeite.goto(detail); await expect(operatorSeite.getByText("verschlüsselt gespeichert")).toBeVisible(); await expect(operatorSeite.getByRole("button", { name: "anzeigen (Re-Auth)" })).toHaveCount(0); await expect(operatorSeite.getByRole("button", { name: "Private Key löschen" })).toHaveCount(0); await operator.close(); // Loeschen (Bestaetigung ist noch frisch). await page.getByRole("button", { name: "Private Key löschen" }).click(); await dialog.getByText("Ich habe verstanden").click(); await dialog.getByRole("button", { name: "Endgültig löschen" }).click(); await expect(page.getByText(/gelöscht am/)).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("button", { name: "anzeigen (Re-Auth)" })).toHaveCount(0); // Im Audit stehen Anzeige, Download und Loeschen mit Fingerprint – ohne den Key. expect(fingerprint).toMatch(/^SHA256:/); const { rows } = await datenbank((client) => client.query<{ aktion: string; zeile: string }>( `SELECT "aktion", row_to_json(p)::text AS zeile FROM "Protokoll" p WHERE "fingerprint" = $1 ORDER BY "id"`, [fingerprint], ), ); expect(rows.map((r) => r.aktion)).toEqual( expect.arrayContaining(["privatekey.angezeigt", "privatekey.heruntergeladen", "privatekey.geloescht"]), ); expect(rows.some((r) => r.zeile.includes("PRIVATE KEY"))).toBe(false); });