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>
118 lines
5.7 KiB
TypeScript
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);
|
|
});
|