Files
sshkeyvault/tests/e2e/rotation.spec.ts
T
florian.wildeandClaude Opus 5.5 f18fd4ec06 feat(rotation): laufende Rotation abbrechen (#032)
Bisher liess sich eine angefangene Rotation nur zu Ende fuehren. Wer
merkt, dass der neue Schluessel nicht passt, braucht einen Weg zurueck,
ohne den alten anzufassen.

"Rotation abbrechen" (Rotationsseite und Seite des Nachfolgers) merkt
den neuen Schluessel ueber den bestehenden Widerruf aus #024 vor; er
kann schon ausgerollt sein, deshalb folgen Dry-Run und Anwenden wie
sonst. Der alte bleibt aktiv und unveraendert. Wo beide denselben
Alias tragen, zeigt die config danach wieder auf die alte Datei, weil
privat.ts nur die verbleibenden Zuweisungen in den Block schreibt.

Zustandsmodell: kein neues Feld. Ein Nachfolger im Widerruf (oder
widerrufen) ist eine abgebrochene Rotation; vorgaengerId bleibt fuer
die Historie, der Abbruch steht als schluessel.rotation.abgebrochen im
Protokoll (in derselben Transaktion wie das Vormerken, dafuer nimmt
widerrufEinleiten optional einen Zusatzschritt an). Abbrechen geht nur,
solange der alte Schluessel noch nicht entfernt wird. widerrufAbschliessen
macht aus dem Widerruf des alten Schluessels kein ROTIERT mehr, wenn
der Nachfolger selbst im Widerruf ist. Weil vorgaengerId eindeutig
ist, gibt es nach einem Abbruch keinen zweiten Nachfolger; Seite und
Fehlermeldung sagen das.

Tests: DB-Tests fuer Abbruch, Idempotenz, Sperren und Status;
Integrationstest gegen echte Ziele (neuer Key ausgerollt, abbrechen,
neuer weg, alter in authorized_keys, ssh zu-b von A geht); E2E fuer
den Ablauf im Browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:45:11 +02:00

118 lines
5.7 KiB
TypeScript

import { expect, test } from "@playwright/test";
import {
anmelden,
archivieren,
aufZiel,
hostEinrichten,
kernAufbauen,
PORT,
zieleZuruecksetzen,
} from "./hilfen";
/**
* Gefuehrte Rotation im Browser (#023), gebauter Stand + Worker: Ausgangslage ist der
* Kern-Anwendungsfall. Neuer Schluessel → Zuweisungen uebernehmen → ausrollen → Verbindung
* testen → alten widerrufen. `ssh zu-b` von A funktioniert vorher und nachher.
*/
test.setTimeout(300_000);
test.beforeAll(() => zieleZuruecksetzen());
test("Rotation mit Schrittanzeige", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
const hostB = `zz-e2e-b-${kennung}`;
const zweck = `zz-e2e Rotation ${kennung}`;
await anmelden(page);
await hostEinrichten(page, hostA, PORT.a);
await hostEinrichten(page, hostB, PORT.b);
await kernAufbauen(page, zweck, hostA, hostB);
const altDateien = aufZiel("ziel-a", "ls /root/.ssh");
await page.reload();
await page.getByRole("link", { name: "Rotieren" }).click();
await expect(page.getByRole("heading", { name: `„${zweck}“ rotieren` })).toBeVisible();
await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click();
// Schritt 3: neuen Schluessel ausrollen.
await expect(page.getByRole("button", { name: "Dry-Run starten" })).toBeVisible({ timeout: 60_000 });
await page.getByRole("button", { name: "Dry-Run starten" }).click();
// Derselbe Alias, neue Datei: nur die IdentityFile-Zeile aendert sich.
await expect(page.getByText(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/)).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: "docs/anleitung/bilder/rotation-desktop.png", fullPage: true });
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
// Schritt 4: Verbindungstest mit dem neuen Schluessel.
await page.getByRole("button", { name: "Verbindungen testen" }).click({ timeout: 60_000 });
// Schritt 5: alten Schluessel widerrufen.
await page.getByRole("button", { name: "Alten Schlüssel widerrufen" }).click({ timeout: 60_000 });
await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 30_000 });
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByRole("heading", { name: "Rotation abgeschlossen" })).toBeVisible({
timeout: 60_000,
});
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
const neueDateien = aufZiel("ziel-a", "ls /root/.ssh");
const alterKey = altDateien.split("\n").find((d) => d.startsWith("id_ed25519") && !d.endsWith(".pub"))!;
expect(neueDateien.split("\n")).not.toContain(alterKey);
expect(neueDateien).toMatch(/-r\d{8}\n/);
// Kette auf der Seite des neuen Schluessels.
await page.getByRole("link", { name: /\(rotiert / }).click();
const kette = page.getByRole("list", { name: "Rotationskette" });
await expect(kette.getByRole("link", { name: zweck })).toBeVisible();
await expect(kette.getByText("rotiert", { exact: true })).toBeVisible();
await archivieren(page, hostA);
await archivieren(page, hostB);
});
test("Rotation abbrechen, nachdem der neue Schlüssel ausgerollt ist (#032)", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
const hostB = `zz-e2e-b-${kennung}`;
const zweck = `zz-e2e Abbruch ${kennung}`;
zieleZuruecksetzen();
await anmelden(page);
await hostEinrichten(page, hostA, PORT.a);
await hostEinrichten(page, hostB, PORT.b);
const schluesselSeite = await kernAufbauen(page, zweck, hostA, hostB);
await page.reload();
await page.getByRole("link", { name: "Rotieren" }).click();
await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click();
await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 60_000 });
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Verbindungen testen" })).toBeVisible({ timeout: 60_000 });
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).toMatch(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/m);
// Abbrechen: Rueckfrage, dann Widerruf des neuen Schluessels mit Dry-Run.
await page.getByRole("button", { name: "Rotation abbrechen" }).click();
const rueckfrage = page.getByRole("group", { name: "Rotation abbrechen?" });
await expect(rueckfrage.getByText(`„${zweck}“ bleibt aktiv und unverändert`)).toBeVisible();
await rueckfrage.getByRole("button", { name: "Neuen Schlüssel widerrufen" }).click();
await expect(page.getByRole("heading", { name: "Rotation wird abgebrochen" })).toBeVisible();
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByRole("heading", { name: "Rotation abgebrochen" })).toBeVisible({ timeout: 60_000 });
// Auf den Hosts: wie vorher. Der Alias geht wieder mit dem alten Schluessel.
expect(aufZiel("ziel-a", "ls /root/.ssh")).not.toMatch(/-r\d{8}/);
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).not.toMatch(/-r\d{8}/);
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
// Der alte bleibt aktiv; die Kette zeigt den widerrufenen Nachfolger, kein „Rotieren“ mehr.
await page.goto(schluesselSeite);
const kette = page.getByRole("list", { name: "Rotationskette" });
await expect(kette.getByText("widerrufen", { exact: true })).toBeVisible();
await expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen/)).toBeVisible();
await expect(page.getByRole("link", { name: "Rotieren" })).toHaveCount(0);
await archivieren(page, hostA);
await archivieren(page, hostB);
});