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 && (
+ }>
+ Vorlage anlegen
+
+ );
+
+ 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 (
+ <>
+ }
+ onClick={() => dialog.current?.showModal()}
+ >
+ Vorlage archivieren
+
+
+ >
+ );
+}
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 (
+
+ );
+}
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");
+ });
+});