Files
sshkeyvault/tests/e2e/schluessel.spec.ts
T
florian.wildeandClaude Opus 5.5 0bb62450f3 feat(schluessel): Private Key anzeigen, herunterladen, loeschen (#014)
Admins koennen einen gespeicherten Private Key nach frischer
Bestaetigung einmalig anzeigen oder als Datei <slug> herunterladen und
ihn endgueltig aus SSHKeyVault loeschen.

Warum so:
- Der Web-Prozess hat keinen Master-Key. Er bittet den Worker per
  Vorgang, den Key zu oeffnen; der legt ihn mit SSHKV_APP_SECRET
  verschluesselt in Vorgang.ergebnis ab. Das Web holt ihn genau einmal
  ab: ein einziges UPDATE unter Zeilensperre liest und ueberschreibt.
- Nicht abgeholte Keys verfallen nach zwei Minuten: Worker-Takt alle
  30 s, Aufraeumen vor jeder Abholung, und das Web gibt aeltere nicht
  mehr heraus. Die Status-Schnittstelle zeigt nur "bereit".
- Download nur per POST (ein GET-Link oder Vorabruf wuerde die
  Abholung verbrauchen), no-store, Herkunft per Sec-Fetch-Site, weil
  no-referrer bei Formular-POSTs Origin: null sendet.
- Loeschen laeuft als Worker-Vorgang, damit nur geheimnisse.ts die
  Chiffrat-Felder anfasst; eigenes Recht privateKeyLoeschen mit
  Bestaetigung. Deployment-Keys gibt sshkv weder heraus noch loescht es
  sie.
- Protokoll: angefordert, angezeigt, heruntergeladen, geloescht - mit
  Fingerprint, nie mit Key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:51:04 +02:00

198 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
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<string, unknown>;
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 <slug>, 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);
});