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>
This commit is contained in:
2026-10-12 17:45:11 +02:00
co-authored by Claude Opus 5.5
parent fb56fd36c5
commit f18fd4ec06
12 changed files with 549 additions and 61 deletions
+46
View File
@@ -69,3 +69,49 @@ test("Rotation mit Schrittanzeige", async ({ page }) => {
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);
});