diff --git a/docs/anleitung/bilder/assistent-fertig-desktop.png b/docs/anleitung/bilder/assistent-fertig-desktop.png new file mode 100644 index 0000000..8b12fdf Binary files /dev/null and b/docs/anleitung/bilder/assistent-fertig-desktop.png differ diff --git a/docs/anleitung/bilder/assistent-ziel-desktop.png b/docs/anleitung/bilder/assistent-ziel-desktop.png new file mode 100644 index 0000000..2690a7b Binary files /dev/null and b/docs/anleitung/bilder/assistent-ziel-desktop.png differ diff --git a/prisma/migrations/20261012110000_zielkonto_vorgaenge/migration.sql b/prisma/migrations/20261012110000_zielkonto_vorgaenge/migration.sql new file mode 100644 index 0000000..8baa5b7 --- /dev/null +++ b/prisma/migrations/20261012110000_zielkonto_vorgaenge/migration.sql @@ -0,0 +1,10 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ANLEGEN'; +ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ENTFERNEN'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b67b8c..38d6161 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,6 +110,10 @@ enum VorgangsArt { SCHLUESSEL_IMPORTIEREN /// Gespeicherten Private Key endgueltig verwerfen (#014). PRIVATE_KEY_LOESCHEN + /// Zielbenutzer anlegen und sudoers setzen (#018). + ZIELKONTO_ANLEGEN + /// sudoers und – falls von sshkv angelegt – den Benutzer entfernen (#018, #024). + ZIELKONTO_ENTFERNEN } enum VorgangsStatus { diff --git a/src/app/(app)/assistent/bestaetigt/page.tsx b/src/app/(app)/assistent/bestaetigt/page.tsx new file mode 100644 index 0000000..b49a2a9 --- /dev/null +++ b/src/app/(app)/assistent/bestaetigt/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; + +export const metadata: Metadata = { title: "Bestätigt" }; + +/** Ziel der Bestaetigung aus dem Assistenten (#020): der Assistent wartet im anderen Tab. */ +export default async function BestaetigtSeite() { + await sitzungErforderlich(); + return ( + + ); +} diff --git a/src/app/(app)/assistent/page.tsx b/src/app/(app)/assistent/page.tsx new file mode 100644 index 0000000..851516e --- /dev/null +++ b/src/app/(app)/assistent/page.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; +import { Assistent } from "@/components/assistent/Assistent"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { assistentDaten } from "@/server/queries/assistent"; +import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht"; + +export const metadata: Metadata = { title: "Host-zu-Host-Assistent" }; + +/** + * Host-zu-Host-Assistent (#020): ein Schluesselpaar fuer genau einen Zweck, Private Key auf + * Host A, Public Key auf Host B, Verbindungstest. Nur Admins – es wird ein Private Key ausgerollt. + */ +export default async function AssistentSeite() { + const { benutzer } = await sitzungErforderlich("privateKeyAusrollen", "/assistent"); + const daten = await assistentDaten(); + return ( +
+ } + titel="Host-zu-Host-Assistent" + untertitel="Host A soll sich per SSH an Host B anmelden – Schlüssel, Ausrollen und Test in einem Durchgang." + /> + +
+ ); +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index a6a1447..775a60f 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -53,7 +53,7 @@ export default async function Uebersicht() { aktionen={ <> - + + + )} + + {schritt === 4 && ablauf.schluesselId && ( + <> + + + + + {ablauf.privatVerwendungId && ( + + )} +

+ Zum Schlüssel:{" "} + + {w.zweck} + +

+
+ + )} + + {schritt < 3 && ( +
+ + +
+ )} + {schritt === 3 && !ablauf.schluesselId && ( +
+ +
+ )} + + ); +} diff --git a/src/server/actions/assistent.ts b/src/server/actions/assistent.ts new file mode 100644 index 0000000..1372cf8 --- /dev/null +++ b/src/server/actions/assistent.ts @@ -0,0 +1,123 @@ +"use server"; + +import { ZielkontoFehler } from "@/lib/ssh/zielkonto"; +import { z } from "@/lib/validierung"; +import { akteurVon } from "@/server/actions/formular"; +import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { HostFehler } from "@/server/services/hosts"; +import { erzeugenAnfordern } from "@/server/services/schluessel"; +import { + VerwendungsFehler, + privateVerwendungAnlegen, + publicVerwendungAnlegen, +} from "@/server/services/verwendungen"; +import { zielkontoAnfordern } from "@/server/services/zielkonten"; + +/** + * Schritte des Host-zu-Host-Assistenten (#020). Jeder Schritt ist eine eigene Aktion, die + * Oberflaeche verkettet sie und wartet dazwischen auf den Worker. So bleibt jede Aktion + * duenn, und jeder Schritt hat seine eigene Rechtepruefung. + */ +export type SchrittAntwort = { + readonly vorgangId?: string; + readonly privatVerwendungId?: string; + readonly fehler?: string; + /** Kritischer Schritt ohne frische Bestaetigung: dorthin, dann weiter. */ + readonly bestaetigen?: boolean; +}; + +function alsAntwort(fehler: unknown): SchrittAntwort { + if (fehler instanceof BestaetigungFehler) return { bestaetigen: true, fehler: fehler.message }; + if ( + fehler instanceof RechteFehler || + fehler instanceof VerwendungsFehler || + fehler instanceof ZielkontoFehler || + fehler instanceof HostFehler + ) { + return { fehler: fehler.message }; + } + throw fehler; +} + +const id = z.string().min(1).max(64); + +export async function assistentErzeugenAktion(eingabe: { + name: string; + vorlageId: string | null; + ablaufAm: string | null; +}): Promise { + try { + const { benutzer } = await rechtErforderlich("privateKeyAusrollen"); + return { + vorgangId: await erzeugenAnfordern( + { + name: eingabe.name, + vorlageId: eingabe.vorlageId, + ablaufAm: eingabe.ablaufAm, + tags: ["host-zu-host"], + }, + undefined, + akteurVon(benutzer), + ), + }; + } catch (fehler) { + return alsAntwort(fehler); + } +} + +export async function assistentZielkontoAktion(eingabe: { + hostId: string; + benutzer: string; + modus: "KEINER" | "BEFEHLE" | "VOLL"; + befehle: string[]; +}): Promise { + try { + // Ein neuer Benutzer mit sudo ist so viel wert wie root: Admin mit frischer Bestaetigung. + const { benutzer } = await rechtErforderlich("zielkontenVerwalten"); + return { + vorgangId: await zielkontoAnfordern({ ...eingabe, hostId: id.parse(eingabe.hostId) }, benutzer), + }; + } catch (fehler) { + return alsAntwort(fehler); + } +} + +export async function assistentZuweisenAktion(eingabe: { + schluesselId: string; + quelle: { hostId: string; benutzer: string; pfad: string }; + ziel: { hostId: string; benutzer: string; optionen: string | null }; + ssh: { alias: string; hostName: string; port: number; knownHosts: boolean }; +}): Promise { + try { + const { benutzer } = await rechtErforderlich("privateKeyAusrollen"); + const akteur = akteurVon(benutzer); + const privatVerwendungId = await privateVerwendungAnlegen( + { + schluesselId: id.parse(eingabe.schluesselId), + hostId: id.parse(eingabe.quelle.hostId), + zielbenutzer: eingabe.quelle.benutzer, + pfad: eingabe.quelle.pfad, + sshConfig: { + alias: eingabe.ssh.alias, + hostName: eingabe.ssh.hostName, + benutzer: eingabe.ziel.benutzer, + port: eingabe.ssh.port, + }, + knownHostsFuer: eingabe.ssh.knownHosts ? [id.parse(eingabe.ziel.hostId)] : [], + }, + akteur, + ); + await publicVerwendungAnlegen( + { + schluesselId: eingabe.schluesselId, + hostId: id.parse(eingabe.ziel.hostId), + zielbenutzer: eingabe.ziel.benutzer, + optionen: eingabe.ziel.optionen, + }, + akteur, + ); + return { privatVerwendungId }; + } catch (fehler) { + return alsAntwort(fehler); + } +} diff --git a/src/server/queries/assistent.ts b/src/server/queries/assistent.ts new file mode 100644 index 0000000..52a9c36 --- /dev/null +++ b/src/server/queries/assistent.ts @@ -0,0 +1,44 @@ +import { prisma } from "@/lib/db"; + +/** Was der Host-zu-Host-Assistent zur Auswahl braucht (#020) – nur oeffentliche Angaben. */ +export async function assistentDaten() { + const [hosts, vorlagen] = await Promise.all([ + prisma.host.findMany({ + where: { archiviertAm: null }, + select: { + id: true, + name: true, + adresse: true, + port: true, + bootstrapAm: true, + hostSchluessel: { where: { status: "VERTRAUT" }, select: { algorithmus: true, fingerprint: true } }, + }, + orderBy: { name: "asc" }, + }), + prisma.vorlage.findMany({ + where: { archiviertAm: null, kategorie: "DIENST" }, + select: { + id: true, + name: true, + beschreibung: true, + standardLaufzeitTage: true, + zielbenutzer: true, + zielbenutzerAnlegen: true, + sudoModus: true, + sudoBefehle: true, + authorizedKeysOptionen: true, + sshConfigAlias: true, + knownHostsEintragen: true, + }, + orderBy: [{ eingebaut: "desc" }, { name: "asc" }], + }), + ]); + return { + hosts: hosts + .filter((h) => h.bootstrapAm !== null) + .map((h) => ({ id: h.id, name: h.name, adresse: h.adresse, port: h.port, hostKeys: h.hostSchluessel })), + vorlagen, + }; +} + +export type AssistentDaten = Awaited>; diff --git a/src/server/services/zielkonten.ts b/src/server/services/zielkonten.ts new file mode 100644 index 0000000..c0df3e4 --- /dev/null +++ b/src/server/services/zielkonten.ts @@ -0,0 +1,31 @@ +import { BENUTZERNAME_MUSTER } from "@/lib/shell"; +import { istGeschuetzt, sudoersInhalt, ZielkontoFehler, type SudoModus } from "@/lib/ssh/zielkonto"; +import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge"; + +/** + * Zielkonten anfordern (#018). Nur Admins mit frischer Bestaetigung (Recht + * zielkontenVerwalten) – ein neuer Benutzer mit sudo ist so viel wert wie root. + */ +export function zielkontoEingabePruefen( + benutzer: string, + modus: SudoModus, + befehle: readonly string[], +): void { + if (!BENUTZERNAME_MUSTER.test(benutzer)) throw new ZielkontoFehler("Ungültiger Benutzername."); + if (istGeschuetzt(benutzer)) + throw new ZielkontoFehler(`„${benutzer}“ verwaltet sshkv nicht als Zielkonto.`); + sudoersInhalt(benutzer, modus, befehle); +} + +export async function zielkontoAnfordern( + eingabe: { hostId: string; benutzer: string; modus: SudoModus; befehle: readonly string[] }, + akteur: { id: string }, +): Promise { + zielkontoEingabePruefen(eingabe.benutzer, eingabe.modus, eingabe.befehle); + return vorgangEinreihen({ + art: "ZIELKONTO_ANLEGEN", + angefordertVonId: akteur.id, + hostId: eingabe.hostId, + eingabe: { benutzer: eingabe.benutzer, modus: eingabe.modus, befehle: [...eingabe.befehle] }, + }); +} diff --git a/src/worker/ablaeufe/zielkonto.ts b/src/worker/ablaeufe/zielkonto.ts new file mode 100644 index 0000000..077990a --- /dev/null +++ b/src/worker/ablaeufe/zielkonto.ts @@ -0,0 +1,98 @@ +import { prisma } from "@/lib/db"; +import { sudoersDatei, sudoersInhalt, type SudoModus } from "@/lib/ssh/zielkonto"; +import { protokollieren } from "@/server/services/protokoll"; +import { VorgangsFehler, type Handler } from "@/worker/ausfuehren"; +import { hostLaden } from "@/worker/ablaeufe/hostkey"; +import { hostVerbinden } from "@/worker/ssh/host"; +import { zielkontoAnwenden, zielkontoEntfernen } from "@/worker/ssh/zielkonto"; + +/** Vorgaenge ZIELKONTO_ANLEGEN und ZIELKONTO_ENTFERNEN (#018). */ +type Eingabe = { benutzer?: unknown; modus?: unknown; befehle?: unknown }; + +function eingabeLesen(roh: unknown): { benutzer: string; modus: SudoModus; befehle: string[] } { + const e = (roh ?? {}) as Eingabe; + if (typeof e.benutzer !== "string") throw new VorgangsFehler("Kein Benutzer angegeben."); + const modus = (["KEINER", "BEFEHLE", "VOLL"] as const).find((m) => m === e.modus) ?? "KEINER"; + const befehle = Array.isArray(e.befehle) ? e.befehle.filter((b): b is string => typeof b === "string") : []; + return { benutzer: e.benutzer, modus, befehle }; +} + +async function akteurVon(id: string | null) { + const benutzer = id + ? await prisma.benutzer.findUnique({ where: { id }, select: { id: true, name: true } }) + : null; + return benutzer ?? ("System" as const); +} + +export const zielkontoAnlegenHandler: Handler = async ({ vorgang }) => { + const host = await hostLaden(vorgang.hostId); + const konto = eingabeLesen(vorgang.eingabe); + const verbindung = await hostVerbinden(host); + let angelegt: boolean; + try { + ({ angelegt } = await zielkontoAnwenden(verbindung, host, konto)); + } finally { + verbindung.end(); + } + const inhalt = sudoersInhalt(konto.benutzer, konto.modus, konto.befehle); + const vorher = await prisma.zielkonto.findUnique({ + where: { hostId_benutzername: { hostId: host.id, benutzername: konto.benutzer } }, + }); + await prisma.zielkonto.upsert({ + where: { hostId_benutzername: { hostId: host.id, benutzername: konto.benutzer } }, + create: { + hostId: host.id, + benutzername: konto.benutzer, + vonSshkvAngelegt: angelegt, + sudoModus: konto.modus, + sudoBefehle: konto.befehle, + sudoersDatei: inhalt ? sudoersDatei(konto.benutzer) : null, + sudoersInhalt: inhalt, + }, + update: { + // Wer den Benutzer einmal angelegt hat, bleibt dabei – sonst loeschte ein Widerruf ihn nie. + vonSshkvAngelegt: (vorher?.vonSshkvAngelegt && !vorher.entferntAm) || angelegt, + sudoModus: konto.modus, + sudoBefehle: konto.befehle, + sudoersDatei: inhalt ? sudoersDatei(konto.benutzer) : null, + sudoersInhalt: inhalt, + entferntAm: null, + }, + }); + await protokollieren({ + akteur: await akteurVon(vorgang.angefordertVonId), + aktion: angelegt ? "zielkonto.angelegt" : "zielkonto.geaendert", + ergebnis: "ERFOLG", + hostName: host.name, + zielbenutzer: konto.benutzer, + diff: inhalt ?? "(keine sudoers-Datei)", + details: { sudoModus: konto.modus }, + }); + return { ergebnis: { angelegt, benutzer: konto.benutzer } }; +}; + +export const zielkontoEntfernenHandler: Handler = async ({ vorgang }) => { + const host = await hostLaden(vorgang.hostId); + const { benutzer } = eingabeLesen(vorgang.eingabe); + const konto = await prisma.zielkonto.findUnique({ + where: { hostId_benutzername: { hostId: host.id, benutzername: benutzer } }, + }); + if (!konto || konto.entferntAm) throw new VorgangsFehler("Dieses Zielkonto verwaltet sshkv nicht (mehr)."); + const verbindung = await hostVerbinden(host); + try { + // Den Benutzer selbst nur loeschen, wenn sshkv ihn angelegt hat. + await zielkontoEntfernen(verbindung, host, benutzer, konto.vonSshkvAngelegt); + } finally { + verbindung.end(); + } + await prisma.zielkonto.update({ where: { id: konto.id }, data: { entferntAm: new Date() } }); + await protokollieren({ + akteur: await akteurVon(vorgang.angefordertVonId), + aktion: "zielkonto.entfernt", + ergebnis: "ERFOLG", + hostName: host.name, + zielbenutzer: benutzer, + details: { benutzerGeloescht: konto.vonSshkvAngelegt }, + }); + return { ergebnis: { benutzerGeloescht: konto.vonSshkvAngelegt } }; +}; diff --git a/src/worker/handler.ts b/src/worker/handler.ts index 78aee35..ebe8e06 100644 --- a/src/worker/handler.ts +++ b/src/worker/handler.ts @@ -5,6 +5,7 @@ import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/ import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey"; import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel"; import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest"; +import { zielkontoAnlegenHandler, zielkontoEntfernenHandler } from "@/worker/ablaeufe/zielkonto"; /** * Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) … @@ -20,4 +21,6 @@ export const HANDLER: HandlerTabelle = { PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler, PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler, VERBINDUNGSTEST: verbindungstestHandler, + ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler, + ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler, }; diff --git a/tests/e2e/assistent.spec.ts b/tests/e2e/assistent.spec.ts new file mode 100644 index 0000000..012ca10 --- /dev/null +++ b/tests/e2e/assistent.spec.ts @@ -0,0 +1,116 @@ +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$/); + } +}); diff --git a/tests/integration/hilfen/host.ts b/tests/integration/hilfen/host.ts index 2c5035e..b8e5a0f 100644 --- a/tests/integration/hilfen/host.ts +++ b/tests/integration/hilfen/host.ts @@ -21,7 +21,14 @@ export function masterKeyAnlegen(): () => void { } export async function vorgangLaufen( - art: "HOSTKEY_ABRUFEN" | "BOOTSTRAP" | "TROCKENLAUF" | "AUSROLLEN" | "VERBINDUNGSTEST", + art: + | "HOSTKEY_ABRUFEN" + | "BOOTSTRAP" + | "TROCKENLAUF" + | "AUSROLLEN" + | "VERBINDUNGSTEST" + | "ZIELKONTO_ANLEGEN" + | "ZIELKONTO_ENTFERNEN", daten: { hostId?: string; eingabe?: object; diff --git a/tests/integration/zielkonto.test.ts b/tests/integration/zielkonto.test.ts index 73ca248..6a8ebf6 100644 --- a/tests/integration/zielkonto.test.ts +++ b/tests/integration/zielkonto.test.ts @@ -118,3 +118,29 @@ describe("Zielkonto", () => { } }); }); + +describe("Zielkonto als Vorgang", () => { + it("legt an, merkt sich die Herkunft und entfernt den Benutzer wieder", async () => { + const { prisma } = await import("@/lib/db"); + const { vorgangLaufen } = await import("./hilfen/host"); + const host = await eingerichteterHost(zielB, "ziel-b"); + const anlegen = await vorgangLaufen("ZIELKONTO_ANLEGEN", { + hostId: host.id, + eingabe: { benutzer: "deploy", modus: "VOLL", befehle: [] }, + }); + expect(anlegen.fehler).toBeNull(); + const konto = await prisma.zielkonto.findFirstOrThrow({ where: { hostId: host.id } }); + expect(konto).toMatchObject({ benutzername: "deploy", vonSshkvAngelegt: true, sudoModus: "VOLL" }); + expect(konto.sudoersInhalt).toContain("deploy ALL=(ALL:ALL) NOPASSWD: ALL"); + + const entfernen = await vorgangLaufen("ZIELKONTO_ENTFERNEN", { + hostId: host.id, + eingabe: { benutzer: "deploy" }, + }); + expect(entfernen.fehler).toBeNull(); + expect((await befehlAlsRoot(zielB, "id deploy 2>&1")).stdout).toMatch(/no such user/); + expect( + (await prisma.zielkonto.findFirstOrThrow({ where: { hostId: host.id } })).entferntAm, + ).not.toBeNull(); + }); +});