diff --git a/docs/anleitung/bilder/vorlage-bearbeiten-desktop.png b/docs/anleitung/bilder/vorlage-bearbeiten-desktop.png new file mode 100644 index 0000000..bb8a8dc Binary files /dev/null and b/docs/anleitung/bilder/vorlage-bearbeiten-desktop.png differ diff --git a/docs/anleitung/bilder/vorlage-bearbeiten-mobil.png b/docs/anleitung/bilder/vorlage-bearbeiten-mobil.png new file mode 100644 index 0000000..d85d8aa Binary files /dev/null and b/docs/anleitung/bilder/vorlage-bearbeiten-mobil.png differ diff --git a/docs/anleitung/bilder/vorlagen-desktop.png b/docs/anleitung/bilder/vorlagen-desktop.png new file mode 100644 index 0000000..9f4587d Binary files /dev/null and b/docs/anleitung/bilder/vorlagen-desktop.png differ diff --git a/docs/anleitung/bilder/vorlagen-mobil.png b/docs/anleitung/bilder/vorlagen-mobil.png new file mode 100644 index 0000000..8f1f0cf Binary files /dev/null and b/docs/anleitung/bilder/vorlagen-mobil.png differ diff --git a/docs/issues/019-vorlagen.md b/docs/issues/019-vorlagen.md index 1b3f521..82874d8 100644 --- a/docs/issues/019-vorlagen.md +++ b/docs/issues/019-vorlagen.md @@ -1,5 +1,5 @@ # #019 Vorlagen -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.3.0 **Labels:** backend, ui **Geschätzt:** 2 h **Tatsächlich:** – @@ -8,12 +8,25 @@ Eingebaute Vorlagen „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in den Einstellungen. ## Aufgaben -- [ ] Platzhalter `{{quellHostIp}}` usw. +- [x] Platzhalter `{{quellHostIp}}` usw. +- [x] Oberfläche: Liste `/vorlagen` (alle lesen), Anlegen/Bearbeiten/Archivieren nur Admin +- [x] Live-Vorschau der aufgelösten Optionen, des Alias und der sudoers-Datei ## Akzeptanzkriterien -- [ ] Vorlage füllt Assistent vor +- [ ] Vorlage füllt Assistent vor (folgt mit dem Assistenten, #020) ## Abhängigkeiten #017, #018 ## Notizen / Entscheidungen +- Vorlagen pflegt nur, wer `einstellungenVerwalten` darf (Admin); lesen darf jede Rolle, weil + Vorlagen keine Geheimnisse enthalten und Operatoren sie im Assistenten sehen. +- Die Vorschau im Formular ruft dieselben reinen Funktionen wie der Server + (`platzhalterErsetzen`, `optionenPruefen`, `sudoersInhalt`) mit eigenen Beispielwerten auf; + Fehler werden dort nur angezeigt, verbindlich prüft der Server beim Speichern. +- Das Befehlsfeld wird bei anderem sudo-Modus nur ausgeblendet, nicht entfernt – so gehen + eingegebene Befehle beim Umschalten nicht verloren; der Service verwirft sie ohnehin. +- Die sudoers-Vorschau bricht nicht um: sudoers ist zeilenbasiert, ein optischer Umbruch sähe + wie eine zweite Regel aus. +- Eingebaute Vorlagen lassen sich bearbeiten, aber nicht archivieren; ihre Bearbeiten-Seite + zeigt deshalb keinen Archivieren-Knopf. diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index e7edef6..9028777 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -39,6 +39,13 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri { name: "meine-schluessel", pfad: "/meine-schluessel" }, { name: "hosts", pfad: "/hosts" }, { name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' }, + { name: "vorlagen", pfad: "/vorlagen" }, + // Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019). + { + name: "vorlage-bearbeiten", + pfad: "/vorlagen", + folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]', + }, ]; const ANSICHTEN = [ diff --git a/src/app/(app)/vorlagen/[id]/page.tsx b/src/app/(app)/vorlagen/[id]/page.tsx new file mode 100644 index 0000000..e83f3e4 --- /dev/null +++ b/src/app/(app)/vorlagen/[id]/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { Badge } from "@/components/ui/Badge"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { VorlageArchivierenDialog } from "@/components/vorlagen/VorlageArchivierenDialog"; +import { VorlagenFormular } from "@/components/vorlagen/VorlagenFormular"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { vorlageDetail } from "@/server/queries/vorlagen"; + +export const metadata: Metadata = { title: "Vorlage bearbeiten" }; + +export default async function VorlageBearbeiten({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + await sitzungErforderlich("einstellungenVerwalten", `/vorlagen/${id}`); + const v = await vorlageDetail(id); + // Archivierte Vorlagen sind fuer die Oberflaeche weg; das Protokoll kennt sie noch (#019). + if (!v || v.archiviertAm) notFound(); + + return ( +
+ } + titel={`${v.name} bearbeiten`} + badges={v.eingebaut ? eingebaut : undefined} + untertitel={ + v.eingebaut + ? "Eingebaute Vorlagen lassen sich anpassen, aber nicht archivieren. Ein späterer Seed überschreibt Ihre Änderungen nicht." + : undefined + } + aktionen={!v.eingebaut && } + /> + +
+ ); +} diff --git a/src/app/(app)/vorlagen/neu/page.tsx b/src/app/(app)/vorlagen/neu/page.tsx new file mode 100644 index 0000000..de32f85 --- /dev/null +++ b/src/app/(app)/vorlagen/neu/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { Breadcrumb } from "@/components/ui/Breadcrumb"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { LEERE_VORLAGE, VorlagenFormular } from "@/components/vorlagen/VorlagenFormular"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; + +export const metadata: Metadata = { title: "Vorlage anlegen" }; + +export default async function VorlageNeu() { + // Vorlagen pflegen nur Admins (#019) – wie die uebrigen Einstellungen. + await sitzungErforderlich("einstellungenVerwalten", "/vorlagen/neu"); + return ( +
+ } + titel="Vorlage anlegen" + untertitel="Eine Vorlage füllt den Assistenten vor; im Einzelfall lässt sich alles noch ändern." + /> + +
+ ); +} diff --git a/src/app/(app)/vorlagen/page.tsx b/src/app/(app)/vorlagen/page.tsx index e7b9e27..e7be395 100644 --- a/src/app/(app)/vorlagen/page.tsx +++ b/src/app/(app)/vorlagen/page.tsx @@ -1,18 +1,137 @@ +import { FileStack, Pencil, Plus } from "lucide-react"; import type { Metadata } from "next"; -import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; +import { Fragment, type ReactNode } from "react"; +import { KategorieBadge } from "@/components/schluessel/Anzeige"; +import { Badge } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { darf } from "@/server/auth/rechte"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { vorlagenListe, type VorlagenListenEintrag } from "@/server/queries/vorlagen"; export const metadata: Metadata = { title: "Vorlagen" }; -export default async function Vorlagen() { - // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). - await sitzungErforderlich(); +const SUDO_TEXT = { KEINER: null, BEFEHLE: "sudo: bestimmte Befehle", VOLL: "sudo: voll" } as const; + +function Angabe({ titel, children }: { titel: string; children: ReactNode }) { return ( - +
+
{titel}
+
{children}
+
+ ); +} + +function Mono({ wert }: { wert: string | null }) { + if (!wert) return –; + // Lange Optionslisten sollen auf 390 px nach einem Komma umbrechen, nicht mitten im Wort (#019). + const teile = wert.split(","); + return ( + + {teile.map((teil, i) => ( + + {teil} + {i < teile.length - 1 && ( + <> + , + + )} + + ))} + + ); +} + +function VorlagenKarte({ v, verwalten }: { v: VorlagenListenEintrag; verwalten: boolean }) { + const sudo = SUDO_TEXT[v.sudoModus]; + return ( + } + aria-label={`${v.name} bearbeiten`} + > + Bearbeiten + + ) + } + className="flex flex-col" + > +
+ + {v.schluesselTyp === "RSA4096" ? "rsa-4096" : "ed25519"} + {v.eingebaut && eingebaut} + {v.standardLaufzeitTage && ( + + {v.standardLaufzeitTage === 1 ? "1 Tag" : `${v.standardLaufzeitTage} Tage`} + + )} +
+ {v.beschreibung &&

{v.beschreibung}

} +
+ + + + {v.zielbenutzerAnlegen && wird angelegt} + {sudo && ( + + {sudo} + {v.sudoModus === "BEFEHLE" && ` (${v.sudoBefehle.length})`} + + )} + + + + + + + + +
+
+ ); +} + +/** Vorlagen (#019): lesen darf jeder, anlegen und bearbeiten nur, wer Einstellungen verwaltet. */ +export default async function Vorlagen() { + // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). + const { benutzer } = await sitzungErforderlich("lesen", "/vorlagen"); + const vorlagen = await vorlagenListe(); + const verwalten = darf(benutzer.rolle, "einstellungenVerwalten"); + const anlegen = verwalten && ( + + ); + + return ( +
+ + {vorlagen.length === 0 ? ( + } + titel="Keine Vorlagen" + text="Die eingebauten Vorlagen legt der Seed an (pnpm db:seed)." + aktion={anlegen} + /> + ) : ( +
+ {vorlagen.map((v) => ( + + ))} +
+ )} +
); } diff --git a/src/components/vorlagen/VorlageArchivierenDialog.tsx b/src/components/vorlagen/VorlageArchivierenDialog.tsx new file mode 100644 index 0000000..d1a4649 --- /dev/null +++ b/src/components/vorlagen/VorlageArchivierenDialog.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Archive } from "lucide-react"; +import { useActionState, useRef } from "react"; +import { Button, Formularfehler } from "@/components/ui"; +import type { FormularZustand } from "@/server/actions/formular"; +import { vorlageArchivierenAktion } from "@/server/actions/vorlagen"; + +/** Archivieren mit Rueckfrage (#019), gleiches Muster wie bei Hosts (#008). */ +export function VorlageArchivierenDialog({ vorlageId, name }: { vorlageId: string; name: string }) { + const dialog = useRef(null); + const [zustand, aktion, laeuft] = useActionState(vorlageArchivierenAktion, {}); + + return ( + <> + + +
+ +

+ „{name}“ archivieren? +

+

+ Die Vorlage steht im Assistenten nicht mehr zur Auswahl. Schlüssel, die mit ihr angelegt wurden, + bleiben unverändert; das Protokoll bleibt erhalten. +

+ +
+ + +
+ +
+ + ); +} diff --git a/src/components/vorlagen/VorlagenFormular.tsx b/src/components/vorlagen/VorlagenFormular.tsx new file mode 100644 index 0000000..d278346 --- /dev/null +++ b/src/components/vorlagen/VorlagenFormular.tsx @@ -0,0 +1,377 @@ +"use client"; + +import { TriangleAlert } from "lucide-react"; +import { useState } from "react"; +import { useFormularAktion } from "@/components/hosts/useFormularAktion"; +import { Ankreuzfeld, Button, Feld, Feldgruppe, Formularfehler, Karte, Textbereich } from "@/components/ui"; +import { MonoBlock } from "@/components/ui/MonoBlock"; +import { optionenPruefen } from "@/lib/ssh/optionen"; +import { sudoersInhalt } from "@/lib/ssh/zielkonto"; +import { PLATZHALTER, platzhalterErsetzen, type PlatzhalterWerte } from "@/lib/vorlagen"; +import { vorlageSpeichernAktion } from "@/server/actions/vorlagen"; + +type SudoModus = "KEINER" | "BEFEHLE" | "VOLL"; + +export type VorlageFormularVorgabe = { + id?: string; + name: string; + beschreibung: string; + schluesselTyp: "ED25519" | "RSA4096"; + kategorie: "DIENST" | "PERSON"; + standardLaufzeitTage: number | null; + zielbenutzer: string; + zielbenutzerAnlegen: boolean; + sudoModus: SudoModus; + sudoBefehle: string[]; + authorizedKeysOptionen: string; + sshConfigAlias: string; + knownHostsEintragen: boolean; + privatNachAusrollenLoeschen: boolean; +}; + +export const LEERE_VORLAGE: VorlageFormularVorgabe = { + name: "", + beschreibung: "", + schluesselTyp: "ED25519", + kategorie: "DIENST", + standardLaufzeitTage: null, + zielbenutzer: "", + zielbenutzerAnlegen: false, + sudoModus: "KEINER", + sudoBefehle: [], + authorizedKeysOptionen: "", + sshConfigAlias: "", + knownHostsEintragen: true, + privatNachAusrollenLoeschen: false, +}; + +/** Beispielwerte der Vorschau – sichtbar andere als die Probewerte des Servers. */ +const BEISPIEL: Required = { + quellHost: "srv-build01", + quellHostIp: "10.0.0.5", + zielHost: "srv-web01", + zielHostIp: "10.0.0.20", +}; + +type Vorschau = { wert: string | null; fehler?: string }; + +/** + * Die Vorschau ist nur Anzeige (#019): Fehler werden zum Hinweis, nicht zum Absturz – die + * verbindliche Pruefung macht der Server beim Speichern mit denselben Funktionen. + */ +function vorschau(berechnen: () => string | null): Vorschau { + try { + return { wert: berechnen() }; + } catch (fehler) { + return { wert: null, fehler: fehler instanceof Error ? fehler.message : String(fehler) }; + } +} + +function befehlsZeilen(text: string): string[] { + return text + .split("\n") + .map((b) => b.trim()) + .filter(Boolean); +} + +function Vorschauzeile({ titel, ergebnis, leer }: { titel: string; ergebnis: Vorschau; leer: string }) { + return ( +
+
{titel}
+
+ {ergebnis.fehler ? ( + {ergebnis.fehler} + ) : ergebnis.wert ? ( + {ergebnis.wert} + ) : ( + {leer} + )} +
+
+ ); +} + +/** Formular fuer neue und bestehende Vorlagen (#019). Regeln prueft der Server. */ +export function VorlagenFormular({ vorgabe }: { vorgabe: VorlageFormularVorgabe }) { + const bearbeiten = Boolean(vorgabe.id); + const { zustand, absenden, laeuft } = useFormularAktion(vorlageSpeichernAktion); + const f = zustand.feldfehler ?? {}; + + // Kontrolliert nur, was die Vorschau braucht; der Rest bleibt unkontrolliert. + const [zielbenutzer, setZielbenutzer] = useState(vorgabe.zielbenutzer); + const [sudoModus, setSudoModus] = useState(vorgabe.sudoModus); + const [befehle, setBefehle] = useState(vorgabe.sudoBefehle.join("\n")); + const [optionen, setOptionen] = useState(vorgabe.authorizedKeysOptionen); + const [alias, setAlias] = useState(vorgabe.sshConfigAlias); + + const optionenVorschau = vorschau(() => + optionen.trim() ? optionenPruefen(platzhalterErsetzen(optionen.trim(), BEISPIEL)) || null : null, + ); + const aliasVorschau = vorschau(() => (alias.trim() ? platzhalterErsetzen(alias.trim(), BEISPIEL) : null)); + const sudoersVorschau = vorschau(() => + sudoModus === "KEINER" ? null : sudoersInhalt(zielbenutzer.trim(), sudoModus, befehlsZeilen(befehle)), + ); + + return ( +
+ {vorgabe.id && } + + + +
+ + +
+
+ + +
+ +
+ ed25519} + erklaerung="Empfohlen – kurz, schnell, sicher." + /> + rsa-4096} + erklaerung="Nur für alte Gegenstellen ohne ed25519." + /> +
+
+ +
+ + +
+
+ +
+
+ + +
+ setZielbenutzer(e.target.value)} + mono + required + maxLength={32} + autoComplete="off" + spellCheck={false} + placeholder="deploy" + hinweis="Kleinbuchstaben, Ziffern, _ und -, höchstens 32 Zeichen." + fehler={f["zielbenutzer"]} + className="sm:max-w-xs" + /> + + +
+ {( + [ + ["KEINER", "keine"], + ["BEFEHLE", "bestimmte Befehle"], + ["VOLL", "voll"], + ] as const + ).map(([wert, text]) => ( + setSudoModus(wert)} + beschriftung={text} + /> + ))} +
+
+ {/* Ausgeblendet statt entfernt, damit die Befehle beim Hin- und Herschalten bleiben. */} + setBefehle(e.target.value)} + rows={4} + spellCheck={false} + placeholder={"/usr/bin/systemctl restart nginx\n/usr/bin/systemctl reload nginx"} + hinweis="Ein Befehl je Zeile, mit absolutem Pfad. Argumente sind erlaubt, Platzhalter nicht." + fehler={f["sudoBefehle"]} + className={sudoModus === "BEFEHLE" ? "[&_textarea]:font-mono [&_textarea]:text-[13px]" : "hidden"} + /> + {sudoModus === "VOLL" && ( +

+ + + Voller sudo-Zugang ohne Passwort: Wer diesen Schlüssel hat, ist auf dem Zielhost faktisch{" "} + root. Nur wählen, wenn bestimmte Befehle nicht + reichen. + +

+ )} +
+
+ + +
+ setOptionen(e.target.value)} + mono + autoComplete="off" + spellCheck={false} + placeholder={'from="{{quellHostIp}}",no-port-forwarding'} + fehler={f["authorizedKeysOptionen"]} + /> + setAlias(e.target.value)} + mono + autoComplete="off" + spellCheck={false} + placeholder="deploy-{{zielHost}}" + fehler={f["sshConfigAlias"]} + /> +
+

+ Platzhalter:{" "} + {PLATZHALTER.map((p, i) => ( + + {i > 0 && ", "} + {`{{${p}}}`} + + ))} + . Beim Ausrollen setzt sshkv Name und IP von Quell- und Zielhost ein. +

+

+ Beispiel: {'from="{{quellHostIp}}"'} wird + zu {`from="${BEISPIEL.quellHostIp}"`}. +

+
+
+ + +
+
+
+ + + Mit Beispielwerten: Quelle {BEISPIEL.quellHost} ( + {BEISPIEL.quellHostIp}), Ziel{" "} + {BEISPIEL.zielHost} ( + {BEISPIEL.zielHostIp}) + + } + > +
+
+ + +
+ {sudoModus !== "KEINER" && + (sudoersVorschau.fehler ? ( +

sudoers: {sudoersVorschau.fehler}

+ ) : ( + sudoersVorschau.wert && ( +
+

+ /etc/sudoers.d/sshkv-{zielbenutzer.trim()} +

+ {/* Ohne Umbruch: sudoers ist zeilenbasiert, ein optischer Umbruch taeuschte eine + zweite Regel vor (#019). Der Block scrollt dann fuer sich. */} + +
+ ) + ))} +
+
+ +
+ + +
+ + ); +} diff --git a/src/server/queries/vorlagen.ts b/src/server/queries/vorlagen.ts new file mode 100644 index 0000000..bcc7603 --- /dev/null +++ b/src/server/queries/vorlagen.ts @@ -0,0 +1,40 @@ +import { prisma } from "@/lib/db"; + +/** + * Lesemodelle fuer Vorlagen (#019). Vorlagen enthalten keine Geheimnisse – jede angemeldete + * Rolle darf sie sehen; die Seite blendet nur die Bearbeiten-Knoepfe aus. + */ + +const AUSWAHL = { + id: true, + name: true, + beschreibung: true, + eingebaut: true, + schluesselTyp: true, + kategorie: true, + standardLaufzeitTage: true, + zielbenutzer: true, + zielbenutzerAnlegen: true, + sudoModus: true, + sudoBefehle: true, + authorizedKeysOptionen: true, + sshConfigAlias: true, + knownHostsEintragen: true, + privatNachAusrollenLoeschen: true, + archiviertAm: true, +} as const; + +/** Eingebaute zuerst in Seed-Reihenfolge – sie sind der Normalfall, eigene die Ausnahme. */ +export async function vorlagenListe() { + return prisma.vorlage.findMany({ + where: { archiviertAm: null }, + orderBy: [{ eingebaut: "desc" }, { angelegtAm: "asc" }, { name: "asc" }], + select: AUSWAHL, + }); +} + +export type VorlagenListenEintrag = Awaited>[number]; + +export async function vorlageDetail(id: string) { + return prisma.vorlage.findUnique({ where: { id }, select: AUSWAHL }); +} diff --git a/tests/e2e/vorlagen.spec.ts b/tests/e2e/vorlagen.spec.ts new file mode 100644 index 0000000..69c015c --- /dev/null +++ b/tests/e2e/vorlagen.spec.ts @@ -0,0 +1,82 @@ +import { expect, test, type Page } from "@playwright/test"; + +/** + * Vorlagen im gebauten Stand (#019). Testvorlagen heissen zz-e2e-… und werden am Ende des + * Ablaufs archiviert, damit die Entwicklungsdatenbank sauber bleibt. + */ +async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader") { + await page.goto("/anmelden"); + await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click(); + await page.locator(`button[data-rolle="${rolle}"]`).click(); + await page.getByRole("button", { name: "Anmelden", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible(); +} + +test("Admin legt eine Vorlage an, sieht Feldfehler und archiviert sie", async ({ page }) => { + const name = `zz-e2e-${Date.now().toString(36)}`; + await oidcAnmelden(page, "admin"); + await page.goto("/vorlagen"); + await expect(page.getByRole("heading", { name: "Deployment-Zugang" })).toBeVisible(); + await page.getByRole("link", { name: "Vorlage anlegen" }).click(); + await expect(page).toHaveURL(/\/vorlagen\/neu$/); + + await page.getByLabel("Name").fill(name); + await page.getByLabel("Zielbenutzer").fill("zzdeploy"); + await page.getByRole("checkbox", { name: /Benutzer anlegen/ }).check(); + await page.getByLabel("authorized_keys-Optionen (optional)").fill('from="{{quellIp}}",no-pty'); + + // Die Vorschau meldet den Fehler schon vor dem Absenden, der Server dann am Feld. + await expect(page.getByText("Unbekannter Platzhalter {{quellIp}}.").first()).toBeVisible(); + await page.getByRole("button", { name: "Vorlage anlegen" }).click(); + await expect(page.locator("#feld-authorizedKeysOptionen-fehler")).toHaveText( + "Unbekannter Platzhalter {{quellIp}}.", + ); + // Eingaben bleiben nach der Feldmeldung stehen. + await expect(page.getByLabel("Name")).toHaveValue(name); + + await page.getByLabel("authorized_keys-Optionen (optional)").fill('from="{{quellHostIp}}",no-pty'); + await page.getByLabel("Alias-Muster für ~/.ssh/config (optional)").fill("zz-{{zielHost}}"); + await expect(page.getByText('from="10.0.0.5",no-pty')).toBeVisible(); + await expect(page.getByText("zz-srv-web01")).toBeVisible(); + + // sudo „bestimmte Befehle“ blendet das Befehlsfeld ein, „voll“ warnt. + await page.getByRole("radio", { name: "voll" }).check(); + await expect(page.getByRole("note")).toContainText("root"); + await page.getByRole("radio", { name: "bestimmte Befehle" }).check(); + await page.getByLabel("Erlaubte Befehle").fill("/usr/bin/systemctl restart nginx"); + await expect(page.getByLabel("sudoers-Inhalt", { exact: true })).toContainText( + "zzdeploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx", + ); + + await page.getByRole("button", { name: "Vorlage anlegen" }).click(); + await expect(page).toHaveURL(/\/vorlagen$/); + const karte = page.locator("article", { has: page.getByRole("heading", { name }) }); + await expect(karte).toBeVisible(); + await expect(karte.getByText('from="{{quellHostIp}}",no-pty')).toBeVisible(); + await expect(karte.getByText("wird angelegt")).toBeVisible(); + + await karte.getByRole("link", { name: `${name} bearbeiten` }).click(); + await page.getByRole("button", { name: "Vorlage archivieren" }).click(); + await page.getByRole("dialog").getByRole("button", { name: "Archivieren" }).click(); + await expect(page).toHaveURL(/\/vorlagen$/); + await expect(page.getByRole("heading", { name })).toHaveCount(0); +}); + +test("eingebaute Vorlagen haben keinen Archivieren-Knopf", async ({ page }) => { + await oidcAnmelden(page, "admin"); + await page.goto("/vorlagen"); + await page.getByRole("link", { name: "Deployment-Zugang bearbeiten" }).click(); + await expect(page.getByLabel("Name")).toHaveValue("Deployment-Zugang"); + await expect(page.getByRole("button", { name: "Vorlage archivieren" })).toHaveCount(0); +}); + +test("Leser sieht Vorlagen, darf aber keine anlegen oder bearbeiten", async ({ page }) => { + await oidcAnmelden(page, "reader"); + await page.goto("/vorlagen"); + await expect(page.getByRole("heading", { name: "Deployment-Zugang" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Vorlage anlegen" })).toHaveCount(0); + await expect(page.getByRole("link", { name: /bearbeiten/ })).toHaveCount(0); + + await page.goto("/vorlagen/neu"); + await expect(page).toHaveURL(/\/kein-zugriff/); +}); diff --git a/tests/vorlagen-service.test.ts b/tests/vorlagen-service.test.ts new file mode 100644 index 0000000..a8e1754 --- /dev/null +++ b/tests/vorlagen-service.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { prisma } from "@/lib/db"; +import { VorlagenFehler, type VorlageEingabe } from "@/lib/vorlagen"; +import { vorlagenListe } from "@/server/queries/vorlagen"; +import { vorlageAnlegen, vorlageArchivieren, vorlageBearbeiten } from "@/server/services/vorlagen"; +import { datenbankLeeren } from "./hilfen/db"; + +/** Vorlagen-Service (#019) gegen die Testdatenbank: Eindeutigkeit, Protokoll, Schutz der eingebauten. */ + +let akteur: { id: string; name: string }; + +beforeEach(async () => { + await datenbankLeeren(); + const benutzer = await prisma.benutzer.create({ data: { name: "Ada Admin", rolle: "ADMIN" } }); + akteur = { id: benutzer.id, name: benutzer.name }; +}); + +const BASIS: VorlageEingabe = { + name: "Monitoring", + beschreibung: " Zugang für Prometheus ", + schluesselTyp: "ED25519", + kategorie: "DIENST", + standardLaufzeitTage: 30, + zielbenutzer: "monitor", + zielbenutzerAnlegen: true, + sudoModus: "BEFEHLE", + sudoBefehle: ["/usr/bin/systemctl status nginx"], + authorizedKeysOptionen: 'from="{{quellHostIp}}",no-pty', + sshConfigAlias: "mon-{{zielHost}}", + knownHostsEintragen: true, + privatNachAusrollenLoeschen: false, +}; + +async function fehlerVon(versuch: Promise): Promise { + const fehler = await versuch.then( + () => undefined, + (f: unknown) => f, + ); + expect(fehler).toBeInstanceOf(VorlagenFehler); + return fehler as VorlagenFehler; +} + +describe("Vorlage anlegen", () => { + it("speichert bereinigt, laesst Platzhalter stehen und protokolliert", async () => { + const id = await vorlageAnlegen(BASIS, akteur); + const v = await prisma.vorlage.findUniqueOrThrow({ where: { id } }); + expect(v.eingebaut).toBe(false); + expect(v.beschreibung).toBe("Zugang für Prometheus"); + expect(v.sudoBefehle).toEqual(["/usr/bin/systemctl status nginx"]); + expect(v.authorizedKeysOptionen).toBe('from="{{quellHostIp}}",no-pty'); + expect(v.sshConfigAlias).toBe("mon-{{zielHost}}"); + + const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "vorlage.angelegt" } }); + expect(eintrag.akteurId).toBe(akteur.id); + expect(eintrag.details).toMatchObject({ name: "Monitoring" }); + expect((await vorlagenListe()).map((l) => l.name)).toEqual(["Monitoring"]); + }); + + it("verwirft sudo-Befehle, wenn der Modus nicht BEFEHLE ist", async () => { + const id = await vorlageAnlegen({ ...BASIS, sudoModus: "KEINER" }, akteur); + expect((await prisma.vorlage.findUniqueOrThrow({ where: { id } })).sudoBefehle).toEqual([]); + }); + + it("meldet einen doppelten Namen am Feld name", async () => { + await vorlageAnlegen(BASIS, akteur); + const fehler = await fehlerVon(vorlageAnlegen({ ...BASIS, zielbenutzer: "anders" }, akteur)); + expect(fehler.feld).toBe("name"); + expect(await prisma.vorlage.count()).toBe(1); + }); + + it("meldet einen unbekannten Platzhalter am Feld der Optionen", async () => { + const fehler = await fehlerVon( + vorlageAnlegen({ ...BASIS, authorizedKeysOptionen: 'from="{{quellIp}}"' }, akteur), + ); + expect(fehler.feld).toBe("authorizedKeysOptionen"); + expect(fehler.message).toContain("{{quellIp}}"); + expect(await prisma.vorlage.count()).toBe(0); + }); +}); + +describe("Vorlage bearbeiten", () => { + it("protokolliert genau die geaenderten Felder", async () => { + const id = await vorlageAnlegen(BASIS, akteur); + await vorlageBearbeiten( + id, + { ...BASIS, standardLaufzeitTage: 60, sudoBefehle: ["/usr/bin/systemctl status nginx", "/usr/bin/id"] }, + akteur, + ); + const v = await prisma.vorlage.findUniqueOrThrow({ where: { id } }); + expect(v.standardLaufzeitTage).toBe(60); + + const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "vorlage.geaendert" } }); + const details = eintrag.details as { name: string; geaendert: string[] }; + expect(details.name).toBe("Monitoring"); + expect(details.geaendert.sort()).toEqual(["standardLaufzeitTage", "sudoBefehle"]); + }); + + it("meldet beim Umbenennen auf einen vergebenen Namen das Feld name", async () => { + await vorlageAnlegen(BASIS, akteur); + const id = await vorlageAnlegen({ ...BASIS, name: "Zweite" }, akteur); + const fehler = await fehlerVon(vorlageBearbeiten(id, BASIS, akteur)); + expect(fehler.feld).toBe("name"); + }); + + it("verweigert archivierte Vorlagen", async () => { + const id = await vorlageAnlegen(BASIS, akteur); + await vorlageArchivieren(id, akteur); + await fehlerVon(vorlageBearbeiten(id, BASIS, akteur)); + }); +}); + +describe("Vorlage archivieren", () => { + it("archiviert eigene Vorlagen und blendet sie aus der Liste aus", async () => { + const id = await vorlageAnlegen(BASIS, akteur); + await vorlageArchivieren(id, akteur); + expect((await prisma.vorlage.findUniqueOrThrow({ where: { id } })).archiviertAm).not.toBeNull(); + expect(await vorlagenListe()).toEqual([]); + expect(await prisma.protokoll.count({ where: { aktion: "vorlage.archiviert" } })).toBe(1); + }); + + it("laesst eingebaute Vorlagen nicht archivieren, aber bearbeiten", async () => { + const eingebaut = await prisma.vorlage.create({ + data: { name: "Deployment-Zugang", eingebaut: true, zielbenutzer: "deploy" }, + }); + await fehlerVon(vorlageArchivieren(eingebaut.id, akteur)); + expect((await prisma.vorlage.findUniqueOrThrow({ where: { id: eingebaut.id } })).archiviertAm).toBeNull(); + + await vorlageBearbeiten(eingebaut.id, { ...BASIS, name: "Deployment-Zugang" }, akteur); + const v = await prisma.vorlage.findUniqueOrThrow({ where: { id: eingebaut.id } }); + expect(v.eingebaut).toBe(true); + expect(v.zielbenutzer).toBe("monitor"); + }); +});