import { execFileSync } from "node:child_process"; import { expect, test, type Page } from "@playwright/test"; import "dotenv/config"; /** * Host-zu-Host-Assistent mit der Vorlage „Deployment-Zugang“ (#018–#020), gebauter Stand + * Worker: Benutzer deploy auf B anlegen, Private Key auf A, Public Key auf B, Dry-Run, * ausrollen, Verbindung testen. */ 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) { 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 }); } test.beforeAll(() => { const reset = "userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true"; aufZiel("ziel-a", reset); aufZiel("ziel-b", reset); }); test("Deployment-Zugang mit dem Assistenten", async ({ page }) => { const kennung = Date.now().toString(36); const hostA = `zz-e2e-a-${kennung}`; const hostB = `zz-e2e-b-${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 hostEinrichten(page, hostA, PORT.a); await hostEinrichten(page, hostB, PORT.b); // Frische Bestaetigung vorab (Benutzer anlegen verlangt sie); der Mock braucht auth_time. await page.goto("/bestaetigen?ziel=%2Fassistent"); await page.getByRole("link", { name: /erneut anmelden/ }).click(); await page.locator('button[data-rolle="admin"]').click(); const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record; await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) })); await page.getByRole("button", { name: "Anmelden", exact: true }).click(); await expect(page.getByRole("heading", { name: "Host-zu-Host-Assistent" })).toBeVisible(); // 1. Vorlage & Zweck await page.getByLabel("Vorlage").selectOption({ label: "Deployment-Zugang" }); await page.getByLabel("Zweck").fill(`zz-e2e Claude Code Deployment ${kennung}`); await expect(page.getByLabel("Ablaufdatum (optional)")).not.toHaveValue(""); await page.getByRole("button", { name: "Weiter" }).click(); // 2. Quelle await page.getByLabel("Host A").selectOption({ label: hostA }); await page.getByRole("button", { name: "Weiter" }).click(); // 3. Ziel await page.getByLabel("Host B").selectOption({ label: hostB }); await expect(page.getByLabel("Benutzer auf B")).toHaveValue("deploy"); await expect(page.getByLabel("Einschränkungen in authorized_keys")).toHaveValue('from="127.0.0.1"'); await expect(page.getByLabel("Alias auf A")).toHaveValue(`deploy-${hostB}`); // Im Docker-Netz kommt A nicht von 127.0.0.1 und erreicht B unter seinem Dienstnamen. await page.getByLabel("Einschränkungen in authorized_keys").fill("no-agent-forwarding"); await page.getByLabel("HostName (aus Sicht von A)").fill("ziel-b"); await page.getByLabel("Port").fill("22"); await page.getByText("Fingerprint von B ist bestätigt").click(); await page.screenshot({ path: "docs/anleitung/bilder/assistent-ziel-desktop.png", fullPage: true }); await page.getByRole("button", { name: "Weiter" }).click(); // 4. Pruefen und vorbereiten await expect(page.getByText(`„deploy“ auf ${hostB} anlegen`)).toBeVisible(); await page.getByRole("button", { name: "Vorbereiten" }).click(); await expect(page.getByRole("button", { name: "Dry-Run starten" })).toBeVisible({ timeout: 90_000 }); // 5. Dry-Run, ausrollen, testen await page.getByRole("button", { name: "Dry-Run starten" }).click(); await expect(page.getByText(`deploy@${hostB}:/home/deploy/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000, }); await page.getByRole("button", { name: /Änderung anwenden/ }).click(); await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 }); await page.getByRole("button", { name: "Verbindung von A nach B testen" }).click(); await expect(page.getByText("Erledigt.")).toBeVisible({ timeout: 30_000 }); await page.screenshot({ path: "docs/anleitung/bilder/assistent-fertig-desktop.png", fullPage: true }); expect(aufZiel("ziel-a", `ssh -o BatchMode=yes deploy-${hostB} id -un`).trim()).toBe("deploy"); for (const name of [hostA, hostB]) { 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$/); } });