import { expect, test, type Page } from "@playwright/test"; import { anmelden, archivieren, aufZiel, datenbank, hostEinrichten, kernAufbauen, oidcBestaetigen, PORT, zieleZuruecksetzen, } from "./hilfen"; /** * Widerruf im Browser (#024), gebauter Stand + Worker: Ausgangslage ist der Kern-Anwendungsfall * (Private Key auf A, Public Key auf B). „Widerrufen“ → Dialog → Dry-Run → anwenden → Status * „widerrufen“; danach kommt A nicht mehr nach B. */ test.setTimeout(240_000); test.beforeAll(() => zieleZuruecksetzen()); test("Widerruf mit einem Klick", async ({ page }) => { const kennung = Date.now().toString(36); const hostA = `zz-e2e-a-${kennung}`; const hostB = `zz-e2e-b-${kennung}`; const zweck = `zz-e2e Widerruf ${kennung}`; await anmelden(page); await hostEinrichten(page, hostA, PORT.a); await hostEinrichten(page, hostB, PORT.b); await kernAufbauen(page, zweck, hostA, hostB); await page.reload(); // Dialog: zeigt genau, was passiert. await page.getByRole("button", { name: "Widerrufen" }).click(); const dialog = page.getByRole("dialog", { name: `„${zweck}“ widerrufen?` }); await expect(dialog.getByText(`root@${hostB}`)).toBeVisible(); await expect(dialog.getByText(`root@${hostA}:~/.ssh/`)).toBeVisible(); await expect(dialog.getByText(/config-Eintrag „Host zu-b“/)).toBeVisible(); await page.screenshot({ path: "docs/anleitung/bilder/widerruf-desktop.png" }); await dialog.getByRole("button", { name: "Widerruf vorbereiten" }).click(); // Zwischenzustand: noch nicht widerrufen, nichts mehr zuweisbar. await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeVisible(); await expect(page.getByText("Widerruf unvollständig")).toBeVisible(); await expect(page.getByRole("button", { name: "Ausrollen" })).toHaveCount(0); await page.getByRole("button", { name: "Dry-Run starten" }).click(); await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 }); await page.screenshot({ path: "docs/anleitung/bilder/widerruf-dry-run-desktop.png", fullPage: true }); await page.getByRole("button", { name: /Änderung anwenden/ }).click(); await expect(page.getByRole("heading", { name: "Widerrufen", exact: true })).toBeVisible({ timeout: 60_000, }); await expect(page.getByText("Widerruf unvollständig")).toHaveCount(0); expect(aufZiel("ziel-a", "ls /root/.ssh")).not.toContain("id_ed25519"); expect(() => aufZiel("ziel-a", "ssh -o BatchMode=yes -o ConnectTimeout=5 zu-b true")).toThrow(); await archivieren(page, hostA); await archivieren(page, hostB); }); /** Schluessel erzeugen, Public Key auf `host` zuweisen und ausrollen. Liefert den Key-Teil. */ async function publicKeyAusrollen(page: Page, zweck: string, host: string): Promise { await page.goto("/schluessel/neu"); await page.getByLabel("Zweck").fill(zweck); await page.getByRole("button", { name: "Schlüssel erzeugen" }).click(); await expect(page.getByRole("heading", { name: zweck })).toBeVisible({ timeout: 30_000 }); const zeile = (await page.getByLabel("Public Key", { exact: true }).textContent()) ?? ""; const keyTeil = zeile.trim().split(/\s+/)[1] ?? ""; expect(keyTeil).toMatch(/^AAAA/); await page.getByRole("button", { name: "Ausrollen" }).nth(1).click(); const oeffentlich = page.getByRole("dialog", { name: "Public Key zuweisen" }); await oeffentlich.getByLabel("Ziel", { exact: true }).selectOption({ label: host }); await oeffentlich.getByRole("button", { name: "Zuweisen" }).click(); await expect(oeffentlich).toBeHidden(); await page.getByRole("button", { name: "Dry-Run starten" }).click(); await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 }); await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 }); return keyTeil; } test("Massenwiderruf mit frischer Bestätigung (#032)", async ({ page }) => { const kennung = Date.now().toString(36); const hostB = `zz-e2e-mb-${kennung}`; const praefix = `zz-e2e Massen ${kennung}`; const zwecke = [`${praefix} eins`, `${praefix} zwei`]; await anmelden(page); // Eine Bestaetigung aus der Anmeldung eben oder einem frueheren Lauf soll nicht mitzaehlen. await datenbank((client) => client.query(`UPDATE "Benutzer" SET "bestaetigtAm" = NULL WHERE "email" = 'admin@sshkv.test'`), ); await hostEinrichten(page, hostB, PORT.b); const keys = []; for (const zweck of zwecke) keys.push(await publicKeyAusrollen(page, zweck, hostB)); const vorher = aufZiel("ziel-b", "cat /root/.ssh/authorized_keys"); for (const key of keys) expect(vorher).toContain(key); // Auswahl in der Liste – ein GET-Formular auf die Massenwiderruf-Seite. await page.goto(`/schluessel?q=${encodeURIComponent(praefix)}`); for (const zweck of zwecke) await page.getByRole("checkbox", { name: `${zweck} für den Massenwiderruf auswählen` }).check(); await page.getByRole("button", { name: "Ausgewählte widerrufen" }).click(); await expect(page).toHaveURL(/\/schluessel\/widerruf\?id=/); await expect(page.getByRole("heading", { name: "2 Schlüssel widerrufen?" })).toBeVisible(); for (const zweck of zwecke) await expect( page.getByRole("region", { name: `Widerruf von ${zweck}` }).getByText(`root@${hostB}`), ).toBeVisible(); // Ohne frische Bestaetigung merkt sshkv nichts vor und schickt zur erneuten Anmeldung. await page.getByRole("button", { name: "2 Schlüssel widerrufen" }).click(); await page.getByRole("link", { name: /Anmeldung bestätigen/ }).click(); await oidcBestaetigen(page); await expect(page).toHaveURL(/\/schluessel\/widerruf\?id=/); const eingeleitet = await datenbank((client) => client.query( `SELECT count(*)::int AS n FROM "Schluessel" WHERE "name" = ANY($1) AND "widerrufEingeleitetAm" IS NOT NULL`, [zwecke], ), ); expect(eingeleitet.rows[0]).toEqual({ n: 0 }); await page.getByRole("button", { name: "2 Schlüssel widerrufen" }).click(); await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeVisible(); // Ein gemeinsamer Dry-Run fuer beide Schluessel, dann anwenden; der Abschluss laeuft danach. await page.getByRole("button", { name: "Dry-Run starten" }).click(); await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 }); await page.getByRole("button", { name: /Änderung anwenden/ }).click(); await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeHidden({ timeout: 60_000 }); const gewaehlt = page.locator("section", { has: page.getByRole("heading", { name: "Gewählte Schlüssel" }), }); await expect(gewaehlt.getByText("widerrufen", { exact: true })).toHaveCount(2); await expect(gewaehlt.getByText("Widerruf unvollständig")).toHaveCount(0); const nachher = aufZiel("ziel-b", "cat /root/.ssh/authorized_keys"); for (const key of keys) expect(nachher).not.toContain(key); const status = await datenbank((client) => client.query(`SELECT "status" FROM "Schluessel" WHERE "name" = ANY($1)`, [zwecke]), ); expect(status.rows).toEqual([{ status: "WIDERRUFEN" }, { status: "WIDERRUFEN" }]); await archivieren(page, hostB); });