fix(widerruf): Massenwiderruf nimmt erzeugte Schluessel an (#032)

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>
This commit is contained in:
2026-10-12 17:44:46 +02:00
co-authored by Claude Opus 5.5
parent be9837ba50
commit fb56fd36c5
7 changed files with 144 additions and 41 deletions
+90 -1
View File
@@ -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<string> {
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);
});