Merge branch 'phase/012-014' (Schluessel, Personen, Private Key)
# Conflicts: # src/components/vorgang/useVorgang.ts # src/worker/handler.ts
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user