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/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/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"); + }); +});