Der neue E2E-Test (zwei im Browser erzeugte Schluessel mit Public Key
auf Ziel B, Auswahl in der Liste, frische Bestaetigung ueber den
Mock-OIDC mit auth_time, gemeinsamer Dry-Run, Anwenden) fand zwei
Fehler im Produkt:
- Die Massenwiderruf-Seite filterte die IDs aus ?id= mit
/^[a-z0-9]{1,64}$/. Vom Worker erzeugte Schluessel tragen aber eine
UUID mit Bindestrichen, nur Prisma-Defaults sind cuids. Damit fiel
jeder erzeugte Schluessel still aus der Auswahl, die Seite meldete
"Keine Schluessel gewaehlt". Der Filter steckt jetzt als
idsAusSuchparameter in src/lib/schluessel.ts, erlaubt Bindestriche
und hat einen eigenen Test.
- Der Hinweis nach verweigerter Bestaetigung sagte "erneut Widerruf
vorbereiten waehlen", der Knopf heisst beim Massenwiderruf aber
"2 Schluessel widerrufen". Er nennt jetzt den Knopf, den es gibt.
Der Test prueft danach, dass beide Keys aus authorized_keys auf B
verschwunden und WIDERRUFEN sind. datenbank() und oidcBestaetigen()
wandern dafuer aus schluessel.spec.ts nach tests/e2e/hilfen.ts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
8.5 KiB
TypeScript
175 lines
8.5 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
||
import "dotenv/config";
|
||
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
|
||
import { datenbank, oidcBestaetigen } from "./hilfen";
|
||
|
||
/**
|
||
* 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/);
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
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);
|
||
});
|