diff --git a/docs/anleitung/bilder/dry-run-desktop.png b/docs/anleitung/bilder/dry-run-desktop.png index 534a8d8..5291b55 100644 Binary files a/docs/anleitung/bilder/dry-run-desktop.png and b/docs/anleitung/bilder/dry-run-desktop.png differ diff --git a/docs/anleitung/bilder/host-eingerichtet-desktop.png b/docs/anleitung/bilder/host-eingerichtet-desktop.png index bf6d99a..1a92e68 100644 Binary files a/docs/anleitung/bilder/host-eingerichtet-desktop.png and b/docs/anleitung/bilder/host-eingerichtet-desktop.png differ diff --git a/docs/anleitung/bilder/kern-ausgerollt-desktop.png b/docs/anleitung/bilder/kern-ausgerollt-desktop.png new file mode 100644 index 0000000..e822a28 Binary files /dev/null and b/docs/anleitung/bilder/kern-ausgerollt-desktop.png differ diff --git a/docs/anleitung/bilder/kern-dry-run-desktop.png b/docs/anleitung/bilder/kern-dry-run-desktop.png new file mode 100644 index 0000000..0020802 Binary files /dev/null and b/docs/anleitung/bilder/kern-dry-run-desktop.png differ diff --git a/src/app/(app)/schluessel/[id]/PrivateAusrollenAktion.tsx b/src/app/(app)/schluessel/[id]/PrivateAusrollenAktion.tsx index bad4e25..9939662 100644 --- a/src/app/(app)/schluessel/[id]/PrivateAusrollenAktion.tsx +++ b/src/app/(app)/schluessel/[id]/PrivateAusrollenAktion.tsx @@ -1,10 +1,35 @@ "use client"; -/** - * Platzhalter fuer „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#012). - * Inhalt folgt mit #017 (Private Key auf einen Host legen); bis dahin bewusst leer. - */ -export function PrivateAusrollenAktion(props: { schluesselId: string; darf: boolean }): null { - void props; - return null; +import { AusrollDialog } from "@/components/ausrollen/AusrollDialog"; +import { PrivateZuweisenFormular } from "@/components/ausrollen/PrivateZuweisenFormular"; +import type { ZielAuswahl } from "@/server/queries/ziele"; + +/** „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#017) – nur Admins. */ +export function PrivateAusrollenAktion({ + schluesselId, + darf, + auswahl, + slug, + passphraseGeschuetzt = false, +}: { + schluesselId: string; + darf: boolean; + auswahl?: ZielAuswahl; + slug?: string; + passphraseGeschuetzt?: boolean; +}) { + if (!darf || !auswahl || !slug) return null; + return ( + + {(schliessen) => ( + + )} + + ); } diff --git a/src/app/(app)/schluessel/[id]/PublicAusrollenAktion.tsx b/src/app/(app)/schluessel/[id]/PublicAusrollenAktion.tsx index 261b3cd..07e7b35 100644 --- a/src/app/(app)/schluessel/[id]/PublicAusrollenAktion.tsx +++ b/src/app/(app)/schluessel/[id]/PublicAusrollenAktion.tsx @@ -1,10 +1,25 @@ "use client"; -/** - * Platzhalter fuer „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#012). - * Inhalt folgt mit #016 (Zuweisung, Dry-Run, Ausrollen); bis dahin bewusst leer. - */ -export function PublicAusrollenAktion(props: { schluesselId: string; darf: boolean }): null { - void props; - return null; +import { AusrollDialog } from "@/components/ausrollen/AusrollDialog"; +import { PublicZuweisenFormular } from "@/components/ausrollen/PublicZuweisenFormular"; +import type { ZielAuswahl } from "@/server/queries/ziele"; + +/** „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#016). */ +export function PublicAusrollenAktion({ + schluesselId, + darf, + auswahl, +}: { + schluesselId: string; + darf: boolean; + auswahl?: ZielAuswahl; +}) { + if (!darf || !auswahl) return null; + return ( + + {(schliessen) => ( + + )} + + ); } diff --git a/src/app/(app)/schluessel/[id]/page.tsx b/src/app/(app)/schluessel/[id]/page.tsx index e6ec5ca..02c98cc 100644 --- a/src/app/(app)/schluessel/[id]/page.tsx +++ b/src/app/(app)/schluessel/[id]/page.tsx @@ -16,6 +16,9 @@ import { datum, datumZeit } from "@/lib/format"; import { darf } from "@/server/auth/rechte"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel"; +import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; +import { EintragAktionen } from "@/components/ausrollen/EintragAktionen"; +import { zielAuswahl } from "@/server/queries/ziele"; import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion"; import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen"; import { PublicAusrollenAktion } from "./PublicAusrollenAktion"; @@ -40,7 +43,15 @@ const ZUSTAND: Record = { FEHLGESCHLAGEN: { status: "drift", text: "fehlgeschlagen" }, }; -type Eintrag = { id: string; ziel: string; href?: string; pfad: string; zusatz?: string; zustand: string }; +type Eintrag = { + id: string; + ziel: string; + href?: string; + pfad: string; + zusatz?: string; + zustand: string; + aktionen?: ReactNode; +}; function AusrollKarte({ titel, @@ -67,21 +78,25 @@ function AusrollKarte({ const z = ZUSTAND[e.zustand] ?? { status: "unbekannt" as const, text: e.zustand }; return (
  • - -
    -

    - {e.href ? ( - - {e.ziel} - - ) : ( - e.ziel - )} -

    -

    {e.pfad}

    - {e.zusatz &&

    {e.zusatz}

    } + +
    +
    +

    + {e.href ? ( + + {e.ziel} + + ) : ( + e.ziel + )} +

    +

    {e.pfad}

    + {e.zusatz &&

    {e.zusatz}

    } +
    +
    - + {/* Aktionen unter dem Eintrag, damit lange Hostnamen nicht umbrechen (#016). */} + {e.aktionen}
  • ); @@ -92,7 +107,7 @@ function AusrollKarte({ ); } -function privateEintraege(s: SchluesselDetail): Eintrag[] { +function privateEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] { return s.privateVerwendungen.map((v) => { const config = v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null; @@ -103,11 +118,22 @@ function privateEintraege(s: SchluesselDetail): Eintrag[] { pfad: v.pfad, ...(typeof config?.alias === "string" ? { zusatz: `config: Host ${config.alias}` } : {}), zustand: v.zustand, + aktionen: ( + + ), }; }); } -function publicEintraege(s: SchluesselDetail): Eintrag[] { +type Rechte = { readonly public: boolean; readonly privat: boolean; readonly testen: boolean }; + +function publicEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] { return s.publicVerwendungen.map((v) => ({ id: v.id, ziel: v.host @@ -121,6 +147,15 @@ function publicEintraege(s: SchluesselDetail): Eintrag[] { pfad: "~/.ssh/authorized_keys", ...(v.optionen ? { zusatz: v.optionen } : {}), zustand: v.zustand, + aktionen: ( + + ), })); } @@ -159,6 +194,12 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id if (!s) notFound(); const aktiv = s.status === "AKTIV"; + const rechte: Rechte = { + public: darf(benutzer.rolle, "publicKeyAusrollen"), + privat: darf(benutzer.rolle, "privateKeyAusrollen"), + testen: darf(benutzer.rolle, "hostsVerwalten"), + }; + const auswahl = aktiv && rechte.public ? await zielAuswahl() : undefined; const personenKey = s.kategorie === "PERSON"; const verwalten = darf(benutzer.rolle, "schluesselVerwalten"); // Den Deployment-Key gibt sshkv nie heraus (#014) – Knoepfe dafuer erscheinen gar nicht erst. @@ -243,7 +284,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id } /> @@ -260,7 +304,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id } /> + {rechte.public && + (s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && ( + + v.zustand !== "AUSGEROLLT").length + + (rechte.privat ? s.privateVerwendungen.filter((v) => v.zustand !== "AUSGEROLLT").length : 0) + } + /> + + )} + void) => ReactNode; +}) { + const dialog = useRef(null); + const [offen, setOffen] = useState(false); + + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (offen && !element.open) element.showModal(); + if (!offen && element.open) element.close(); + }, [offen]); + + const schliessen = () => setOffen(false); + return ( + <> + + +
    +

    {titel}

    + +
    + {offen && children(schliessen)} +
    + + ); +} diff --git a/src/components/ausrollen/AusrollenPanel.tsx b/src/components/ausrollen/AusrollenPanel.tsx index 96d236e..127e66d 100644 --- a/src/components/ausrollen/AusrollenPanel.tsx +++ b/src/components/ausrollen/AusrollenPanel.tsx @@ -14,7 +14,17 @@ import type { DiffZeile } from "@/lib/ssh/zeilen-diff"; * schreibt: der Plan traegt die Hashes, und das Ausrollen bricht ab, wenn sich seitdem auf * dem Host oder an den Zuweisungen etwas geaendert hat. */ +type DateiPlan = { + art: "privateKey" | "publicKey" | "config" | "knownHosts"; + pfad: string; + aktion: "anlegen" | "aendern" | "keine" | "entfernen"; + diff?: DiffZeile[]; + hinweis?: string; +}; + type PlanZiel = { + art?: "public" | "privat"; + dateien?: DateiPlan[]; hostName: string; zielbenutzer: string; pfad?: string; @@ -77,6 +87,8 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of

    {ziel.zielbenutzer}@{ziel.hostName}: {ziel.fehler}

    + ) : ziel.art === "privat" ? ( + ) : (
    + )} + {fehler &&

    {fehler}

    } +
    + ); +} diff --git a/src/components/ausrollen/PrivateZuweisenFormular.tsx b/src/components/ausrollen/PrivateZuweisenFormular.tsx new file mode 100644 index 0000000..66ccebf --- /dev/null +++ b/src/components/ausrollen/PrivateZuweisenFormular.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; +import { Ankreuzfeld, Button, Feld, Formularfehler } from "@/components/ui"; +import { Auswahlliste } from "@/components/ui/Feld"; +import { useFormularAktion } from "@/components/hosts/useFormularAktion"; +import { privateZuweisenAktion } from "@/server/actions/verwendungen"; +import type { ZielAuswahl } from "@/server/queries/ziele"; + +/** + * Private Key auf einen Quell-Host legen (#017), optional mit ~/.ssh/config-Eintrag und dem + * Host-Key des Ziels in known_hosts – das, was ein Cronjob auf dem Quell-Host braucht. + */ +export function PrivateZuweisenFormular({ + schluesselId, + slug, + auswahl, + passphraseGeschuetzt, + beiErfolg, +}: { + schluesselId: string; + slug: string; + auswahl: ZielAuswahl & { hostsDetails: { id: string; name: string; adresse: string; port: number }[] }; + passphraseGeschuetzt: boolean; + beiErfolg?: () => void; +}) { + const [mitConfig, setMitConfig] = useState(true); + const [zielHostId, setZielHostId] = useState(""); + const ziel = auswahl.hostsDetails.find((h) => h.id === zielHostId); + const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => { + const ergebnis = await privateZuweisenAktion(vorher, formular); + if (!ergebnis.fehler && !ergebnis.feldfehler) beiErfolg?.(); + return ergebnis; + }); + + return ( +
    + + + {passphraseGeschuetzt && ( +

    + ▲ Dieser Private Key ist passphrasengeschützt – für automatische Anmeldungen (Host-zu-Host) + ungeeignet. +

    + )} + + + {auswahl.hosts.map((h) => ( + + ))} + +
    + + +
    + + setMitConfig(e.target.checked)} + /> + {mitConfig && ( +
    + setZielHostId(e.target.value)} + > + + {auswahl.hostsDetails.map((h) => ( + + ))} + +
    + + + + +
    +
    + )} + + + + + + + ); +} diff --git a/src/server/actions/verwendungen.ts b/src/server/actions/verwendungen.ts index 0a4e944..f2f01f1 100644 --- a/src/server/actions/verwendungen.ts +++ b/src/server/actions/verwendungen.ts @@ -4,13 +4,17 @@ import { revalidatePath } from "next/cache"; import { z } from "@/lib/validierung"; import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; import { akteurVon, textfeld, type FormularZustand } from "@/server/actions/formular"; +import { darf } from "@/server/auth/rechte"; import { VerwendungsFehler, ausrollenStarten, + privateVerwendungAnlegen, + privateVerwendungEntfernen, publicVerwendungAnlegen, publicVerwendungEntfernen, trockenlaufStarten, } from "@/server/services/verwendungen"; +import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge"; /** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */ @@ -82,6 +86,8 @@ export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise { + try { + const { benutzer } = await rechtErforderlich("privateKeyAusrollen"); + const schluesselId = z.string().min(1).max(64).parse(textfeld(formular, "schluesselId")); + const alias = (textfeld(formular, "alias") ?? "").trim(); + const zielHostId = textfeld(formular, "zielHostId"); + await privateVerwendungAnlegen( + { + schluesselId, + hostId: textfeld(formular, "hostId") ?? "", + zielbenutzer: (textfeld(formular, "zielbenutzer") ?? "").trim(), + pfad: textfeld(formular, "pfad") ?? "", + sshConfig: alias + ? { + alias, + hostName: (textfeld(formular, "hostName") ?? "").trim(), + benutzer: (textfeld(formular, "sshBenutzer") ?? "").trim(), + port: Number(textfeld(formular, "port") ?? "22"), + } + : null, + knownHostsFuer: zielHostId ? [zielHostId] : [], + beimWiderruf: textfeld(formular, "beimWiderruf") === "UMBENENNEN" ? "UMBENENNEN" : "LOESCHEN", + }, + akteurVon(benutzer), + ); + revalidatePath(`/schluessel/${schluesselId}`); + return {}; + } catch (fehler) { + return alsAntwort(fehler); + } +} + +export async function privateEntfernenAktion( + verwendungId: string, + schluesselId: string, +): Promise<{ fehler?: string }> { + try { + const { benutzer } = await rechtErforderlich("privateKeyAusrollen"); + await privateVerwendungEntfernen(z.string().min(1).max(64).parse(verwendungId), akteurVon(benutzer)); + revalidatePath(`/schluessel/${schluesselId}`); + return {}; + } catch (fehler) { + return alsAntwort(fehler); + } +} + +/** Verbindungstest A → B (#020). Harmlos, aber nur fuer Rollen, die Hosts verwalten. */ +export async function verbindungTestenAktion(privatVerwendungId: string): Promise { + try { + const { benutzer } = await rechtErforderlich("hostsVerwalten"); + return { + vorgangId: await vorgangEinreihen({ + art: "VERBINDUNGSTEST", + angefordertVonId: benutzer.id, + eingabe: { privatVerwendungId: z.string().min(1).max(64).parse(privatVerwendungId) }, + }), + }; + } catch (fehler) { + return alsAntwort(fehler); + } +} diff --git a/src/server/queries/ziele.ts b/src/server/queries/ziele.ts index 06717b8..1243daf 100644 --- a/src/server/queries/ziele.ts +++ b/src/server/queries/ziele.ts @@ -5,7 +5,7 @@ export async function zielAuswahl() { const [hosts, gruppen] = await Promise.all([ prisma.host.findMany({ where: { archiviertAm: null }, - select: { id: true, name: true, bootstrapAm: true }, + select: { id: true, name: true, bootstrapAm: true, adresse: true, port: true }, orderBy: { name: "asc" }, }), prisma.hostgruppe.findMany({ @@ -15,6 +15,7 @@ export async function zielAuswahl() { ]); return { hosts: hosts.map((h) => ({ id: h.id, name: h.name, bereit: h.bootstrapAm !== null })), + hostsDetails: hosts.map((h) => ({ id: h.id, name: h.name, adresse: h.adresse, port: h.port })), gruppen: gruppen.map((g) => ({ id: g.id, name: g.name, mitglieder: g._count.mitglieder })), }; } diff --git a/src/server/services/verwendungen.ts b/src/server/services/verwendungen.ts index fb597cc..7dbde1e 100644 --- a/src/server/services/verwendungen.ts +++ b/src/server/services/verwendungen.ts @@ -120,6 +120,8 @@ export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Pro export async function trockenlaufStarten( auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] }, akteur: Akteur, + /** Private-Key-Ziele nur fuer Admins (Freigabe Frage 3) – der Aufrufer entscheidet per darf(). */ + mitPrivat = false, ): Promise { const ziele = auswahl.ziele ?? [ ...(await zieleVonVerwendungen({ @@ -128,7 +130,7 @@ export async function trockenlaufStarten( ...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}), })), // Private Keys nur, wenn nicht gezielt einzelne Public-Verwendungen gemeint sind. - ...(auswahl.verwendungIds + ...(auswahl.verwendungIds || !mitPrivat ? [] : await zieleVonPrivatVerwendungen({ ...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}), diff --git a/tests/e2e/kern-anwendungsfall.spec.ts b/tests/e2e/kern-anwendungsfall.spec.ts new file mode 100644 index 0000000..c685784 --- /dev/null +++ b/tests/e2e/kern-anwendungsfall.spec.ts @@ -0,0 +1,117 @@ +import { execFileSync } from "node:child_process"; +import { expect, test, type Page } from "@playwright/test"; +import "dotenv/config"; + +/** + * Kern-Anwendungsfall im Browser (#012, #016, #017, #020), gebauter Stand + Worker: + * Schluessel erzeugen → Private Key auf Ziel A (mit ~/.ssh/config und known_hosts) → + * Public Key auf Ziel B → Dry-Run → anwenden → Verbindung von A nach B testen. + * Beide Hosts heissen zz-e2e-… und werden am Ende archiviert. + */ +const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test"; +const PORT = { + a: (process.env["SSHKV_TEST_ZIEL_A"] ?? "127.0.0.1:2221").split(":")[1] ?? "2221", + b: (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222", +}; + +test.setTimeout(240_000); + +function aufZiel(ziel: "ziel-a" | "ziel-b", befehl: string): string { + return execFileSync( + "docker", + ["compose", "-f", "docker-compose.test.yml", "exec", "-T", ziel, "sh", "-c", befehl], + { + encoding: "utf8", + }, + ); +} + +async function hostEinrichten(page: Page, name: string, port: string): Promise { + await page.goto("/hosts/neu"); + await page.getByLabel("Name").fill(name); + await page.getByLabel("Adresse").fill("127.0.0.1"); + await page.getByLabel("Port").fill(port); + await page.getByRole("button", { name: "Host anlegen" }).click(); + await page.getByRole("button", { name: "Host-Key abrufen" }).click(); + await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 }); + await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT); + await page.getByRole("button", { name: "Zugang einrichten" }).click(); + await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 }); +} + +async function archivieren(page: Page, name: string): Promise { + await page.goto("/hosts"); + await page.getByRole("link", { name }).first().click(); + await page.getByRole("button", { name: "Host archivieren" }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click(); + await expect(page).toHaveURL(/\/hosts$/); +} + +test.beforeAll(() => { + const reset = "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv; rm -rf /root/.ssh; true"; + aufZiel("ziel-a", reset); + aufZiel("ziel-b", reset); +}); + +test("Backup A → B: erzeugen, ausrollen, Verbindung testen", async ({ page }) => { + const kennung = Date.now().toString(36); + const hostA = `zz-e2e-a-${kennung}`; + const hostB = `zz-e2e-b-${kennung}`; + const zweck = `zz-e2e Backup ${kennung}`; + + await page.goto("/anmelden"); + await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click(); + await page.locator('button[data-rolle="admin"]').click(); + await page.getByRole("button", { name: "Anmelden", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible(); + + await hostEinrichten(page, hostA, PORT.a); + await hostEinrichten(page, hostB, PORT.b); + + // 1. Schluessel erzeugen + 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 }); + + // 2a. Private Key auf A, mit config-Eintrag und Host-Key von B + await page.getByRole("button", { name: "Ausrollen" }).first().click(); + const privat = page.getByRole("dialog", { name: "Private Key ausrollen" }); + await privat.getByLabel("Quell-Host (bekommt den Private Key)").selectOption({ label: hostA }); + await privat.getByLabel("Ziel-Host (für known_hosts)").selectOption({ label: hostB }); + await privat.getByRole("textbox", { name: "Alias" }).fill("zu-b"); + // A erreicht B im Docker-Netz unter dem Dienstnamen auf Port 22. + await privat.getByRole("textbox", { name: "HostName" }).fill("ziel-b"); + await privat.getByRole("spinbutton", { name: "Port" }).fill("22"); + await privat.getByRole("button", { name: "Zuweisen" }).click(); + await expect(privat).toBeHidden(); + await expect(page.getByText(`root@${hostA}`)).toBeVisible(); + + // 2b. Public Key auf B + 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: hostB }); + await oeffentlich.getByLabel(/Einschränkungen/).fill("no-pty,no-agent-forwarding"); + await oeffentlich.getByRole("button", { name: "Zuweisen" }).click(); + await expect(oeffentlich).toBeHidden(); + await expect(page.getByText(`root@${hostB}`)).toBeVisible(); + + // 3. Dry-Run und anwenden + await page.getByRole("button", { name: "Dry-Run starten" }).click(); + await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("Host zu-b").first()).toBeVisible(); + // Kein Private Key im Browser. + expect(await page.content()).not.toContain("PRIVATE KEY"); + await page.screenshot({ path: "docs/anleitung/bilder/kern-dry-run-desktop.png", fullPage: true }); + await page.getByRole("button", { name: /Änderung anwenden/ }).click(); + await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 }); + + // 4. Verbindungstest von A nach B + await page.getByRole("button", { name: "Verbindung testen" }).click(); + await expect(page.getByText("Erledigt.")).toBeVisible({ timeout: 30_000 }); + expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root"); + await page.screenshot({ path: "docs/anleitung/bilder/kern-ausgerollt-desktop.png", fullPage: true }); + + await archivieren(page, hostA); + await archivieren(page, hostB); +});