diff --git a/src/app/(app)/schluessel/widerruf/page.tsx b/src/app/(app)/schluessel/widerruf/page.tsx index f034ab5..28565ac 100644 --- a/src/app/(app)/schluessel/widerruf/page.tsx +++ b/src/app/(app)/schluessel/widerruf/page.tsx @@ -9,6 +9,7 @@ import { Leerzustand } from "@/components/ui/Leerzustand"; import { Seitenkopf } from "@/components/ui/Seitenkopf"; import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf"; import { WiderrufFormular } from "@/components/widerruf/WiderrufFormular"; +import { idsAusSuchparameter } from "@/lib/schluessel"; import { darf } from "@/server/auth/rechte"; import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { widerrufUebersicht } from "@/server/queries/widerruf"; @@ -17,14 +18,6 @@ export const metadata: Metadata = { title: "Massenwiderruf" }; type Suchparameter = Promise<{ id?: string | string[]; zielkonten?: string | string[] }>; -function liste(wert: string | string[] | undefined): string[] { - const roh = Array.isArray(wert) ? wert : wert ? [wert] : []; - return [...new Set(roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9]{1,64}$/i.test(w)))].slice( - 0, - 200, - ); -} - /** * Massenwiderruf (#024): mehrere Schluessel – etwa alle einer Person nach Projektende – in einem * Durchgang mit gemeinsamem Dry-Run. Nur Admins; das Vormerken verlangt eine frische @@ -32,7 +25,7 @@ function liste(wert: string | string[] | undefined): string[] { */ export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) { const parameter = await searchParams; - const ids = liste(parameter.id); + const ids = idsAusSuchparameter(parameter.id); const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`; const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst); @@ -61,7 +54,7 @@ export default async function MassenwiderrufSeite({ searchParams }: { searchPara const offeneStati = (s: { status: string }) => s.status === "AKTIV" || s.status === "ABGELAUFEN"; const nochNichtEingeleitet = uebersicht.schluessel.filter((s) => offeneStati(s) && !s.eingeleitet); const laufend = uebersicht.schluessel.filter((s) => offeneStati(s) && s.eingeleitet); - const vorauswahl = liste(parameter.zielkonten); + const vorauswahl = idsAusSuchparameter(parameter.zielkonten); return (
diff --git a/src/components/widerruf/WiderrufFormular.tsx b/src/components/widerruf/WiderrufFormular.tsx index 96254db..83b032e 100644 --- a/src/components/widerruf/WiderrufFormular.tsx +++ b/src/components/widerruf/WiderrufFormular.tsx @@ -32,6 +32,7 @@ export function WiderrufFormular({ const [laeuft, starten] = useTransition(); const gesperrt = uebersicht.schluessel.filter((s) => s.gesperrt); const mehrere = uebersicht.schluessel.length > 1; + const knopfText = mehrere ? `${uebersicht.schluessel.length} Schlüssel widerrufen` : "Widerruf vorbereiten"; const ziel = (() => { const url = new URL(ruecksprung, "http://x"); @@ -100,7 +101,8 @@ export function WiderrufFormular({ href={`/bestaetigen?ziel=${encodeURIComponent(ruecksprung)}`} className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium" > - Anmeldung bestätigen und danach erneut „Widerruf vorbereiten“ wählen + {/* Nennt den Knopf, den es hier wirklich gibt – beim Massenwiderruf heisst er anders (#032). */} + Anmeldung bestätigen und danach erneut „{knopfText}“ wählen )}
@@ -124,11 +126,7 @@ export function WiderrufFormular({ }) } > - {laeuft - ? "Wird vorgemerkt …" - : mehrere - ? `${uebersicht.schluessel.length} Schlüssel widerrufen` - : "Widerruf vorbereiten"} + {laeuft ? "Wird vorgemerkt …" : knopfText}
diff --git a/src/lib/schluessel.ts b/src/lib/schluessel.ts index dfcb0ae..2c6197d 100644 --- a/src/lib/schluessel.ts +++ b/src/lib/schluessel.ts @@ -293,3 +293,14 @@ export function publicKeyZeileMitKommentar(zeile: string, kommentar: string): st const [typ, base64] = zeile.trim().split(/\s+/); return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`; } + +/** + * IDs aus einem Suchparameter (`?id=a&id=b` oder `?id=a,b`), etwa fuer den Massenwiderruf (#024). + * Erlaubt Bindestriche: Vom Worker erzeugte Schluessel tragen eine UUID, nur Prisma-Defaults sind + * cuids – ohne Bindestrich fielen alle erzeugten Schluessel still aus der Auswahl (#032). + */ +export function idsAusSuchparameter(wert: string | string[] | undefined, hoechstens = 200): string[] { + const roh = Array.isArray(wert) ? wert : wert ? [wert] : []; + const gueltig = roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9-]{1,64}$/i.test(w)); + return [...new Set(gueltig)].slice(0, hoechstens); +} diff --git a/tests/e2e/hilfen.ts b/tests/e2e/hilfen.ts index edcb594..5ff39e6 100644 --- a/tests/e2e/hilfen.ts +++ b/tests/e2e/hilfen.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; import { expect, type Page } from "@playwright/test"; +import pg from "pg"; import "dotenv/config"; /** @@ -35,6 +36,30 @@ export async function anmelden(page: Page): Promise { await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible(); } +/** Direkter Zugriff auf die Datenbank des gebauten Stands – fuer Vorbedingungen und Pruefungen. */ +export 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(); + } +} + +/** + * 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. + */ +export async function oidcBestaetigen(page: Page): Promise { + 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(); +} + export async function hostEinrichten(page: Page, name: string, port: string): Promise { await page.goto("/hosts/neu"); await page.getByLabel("Name").fill(name); diff --git a/tests/e2e/schluessel.spec.ts b/tests/e2e/schluessel.spec.ts index 330747d..cb00e85 100644 --- a/tests/e2e/schluessel.spec.ts +++ b/tests/e2e/schluessel.spec.ts @@ -1,7 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; -import pg from "pg"; 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-… @@ -17,16 +17,6 @@ async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader") 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)}`; } @@ -104,19 +94,6 @@ test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async 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, }) => { diff --git a/tests/e2e/widerruf.spec.ts b/tests/e2e/widerruf.spec.ts index dda76c4..b402e70 100644 --- a/tests/e2e/widerruf.spec.ts +++ b/tests/e2e/widerruf.spec.ts @@ -1,10 +1,12 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; import { anmelden, archivieren, aufZiel, + datenbank, hostEinrichten, kernAufbauen, + oidcBestaetigen, PORT, zieleZuruecksetzen, } from "./hilfen"; @@ -59,3 +61,90 @@ test("Widerruf mit einem Klick", async ({ page }) => { 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); +}); diff --git a/tests/schluessel.test.ts b/tests/schluessel.test.ts index 364b750..994d118 100644 --- a/tests/schluessel.test.ts +++ b/tests/schluessel.test.ts @@ -9,6 +9,7 @@ import { ablaufZustand, fingerprintKurz, freierSlug, + idsAusSuchparameter, slugAus, typAnzeige, erzeugenSchema, @@ -66,6 +67,15 @@ function kern(privateKey: string): string { } describe("Regeln (rein)", () => { + it("liest IDs aus dem Suchparameter, auch UUIDs vom Worker (#032)", () => { + const uuid = "5f703ff1-3877-4af4-af08-84338237fcd8"; + expect(idsAusSuchparameter([uuid, "cm1abc"])).toEqual([uuid, "cm1abc"]); + expect(idsAusSuchparameter(`a,b,a`)).toEqual(["a", "b"]); + expect(idsAusSuchparameter(["x y", "../etc", ""])).toEqual([]); + expect(idsAusSuchparameter(undefined)).toEqual([]); + expect(idsAusSuchparameter(["a", "b", "c"], 2)).toEqual(["a", "b"]); + }); + it.each([ ["Backup srv-backup01 → srv-pve01", "backup-srv-backup01-srv-pve01"], ["Größter Schlüssel für Ärzte", "groesster-schluessel-fuer-aerzte"],