feat(vorlagen): Editor, Liste und Versionsgeschichte
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Der Redakteur baut eine Vorlage jetzt vollstaendig in der Oberflaeche: Liste mit
Suche und Filtern, Editor mit Abschnitten, Schritten und Variablen, Vorschau mit
Beispielwerten, Veroeffentlichen mit Aenderungsnotiz, Versionsgeschichte.

Sortiert wird mit dem Finger, der Maus, der Tastatur **und** zwei Knoepfen an
jeder Zeile — ein Touch-Drag gelingt auf einem 390-px-Geraet nicht jedem, und
die Reihenfolge ist fachlich zu wichtig fuer nur einen Weg. Schritte, Variablen
und Abschnitte klappen zu: dieselbe Vorlage misst am Handy 3 482 statt 16 986 px.

- src/lib/platzhalter.ts als einzige Quelle der eingebauten Platzhalter; der
  Editor warnt vor unbekannten, blockiert aber nichts
- Hilfetexte als Markdown ohne rohes HTML, Bilder als Alternativtext
- Erste Playwright-Tests: der Editor wird durch die Oberflaeche gefahren

Wichtigster Befund des Meilensteins: `next dev` hydriert in dieser Umgebung
ueberhaupt nicht — die Seiten sehen richtig aus, aber kein Knopf tut etwas
(2 von 1302 Elementen tragen React-Knoten, im gebauten Stand 1281 von 1296).
Nicht an diesem Code: auch ohne proxy.ts und ohne output: standalone. Ein
Screenshot beweist damit nichts ueber Bedienbarkeit. Oberflaechen werden ab
sofort gegen `pnpm build && pnpm start` abgenommen, und was nur mit angehaengtem
React gelingt, gehoert in einen Playwright-Test.

Der unabhaengige Review fand zwei Blocker: eine abgelaufene Anmeldung haette im
Editor jede ungespeicherte Arbeit vernichtet (der Rechtefehler lief bis in Nexts
Fehlergrenze), und das Speichern der Kopfdaten loeste eine archivierte
Kundenzuordnung stillschweigend auf. Dazu sechs weitere Befunde, alle behoben.

496 Tests gruen (vorher 428), dazu 5 E2E-Tests.

Schliesst #010

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 10:22:33 +02:00
co-authored by Claude Opus 5
parent d8d5444e9f
commit 548c090562
65 changed files with 5454 additions and 103 deletions
+178
View File
@@ -0,0 +1,178 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Der Vorlagen-Editor durch die Oberflaeche (#010).
*
* Diese Datei gibt es, weil ein Screenshot ueber Bedienbarkeit **nichts**
* aussagt: die Seiten werden serverseitig gerendert und sehen auch dann richtig
* aus, wenn React sich nie anhaengt. Geprueft wird deshalb, was nur mit
* angehaengtem React gelingt — anlegen, tippen, sortieren, speichern — und
* anschliessend, ob es wirklich in der Datenbank steht: durch Neuladen.
*
* Voraussetzung: ein **gebauter** Stand laeuft (`pnpm build && pnpm start`) und
* `SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD` sind gesetzt. Gegen `pnpm dev`
* schlaegt das hier fehl, und das ist richtig so — siehe CLAUDE.md,
* „Oberflaeche pruefen".
*
* Der Ablauf legt seine eigene Vorlage an und **archiviert** sie am Ende. Nicht
* loescht: eine veroeffentlichte Version laesst sich nicht mehr entfernen, auch
* nicht ueber einen Kaskadenloeschvorgang — der Trigger aus #009 verweigert das
* ausdruecklich, und genau das ist die Zusage, an der das ganze Archiv haengt.
* Wer diese Tests oft laufen laesst, braucht deshalb eine Wegwerf-Datenbank
* (kommt mit #018).
*/
const NAME = `ZZ E2E Editor ${Date.now()}`;
async function anmelden(seite: Page) {
const email = process.env["SEED_ADMIN_EMAIL"];
const passwort = process.env["SEED_ADMIN_PASSWORD"];
if (!email || !passwort) throw new Error("SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD fehlen.");
await seite.goto("/anmelden");
await seite.fill('input[name="email"]', email);
await seite.fill('input[name="passwort"]', passwort);
await Promise.all([seite.waitForURL("**/"), seite.click('button[type="submit"]')]);
}
/** Legt die Vorlage an und fuehrt in ihren Entwurf. Gibt die Adresse des Editors zurueck. */
async function entwurfAnlegen(seite: Page, name: string): Promise<string> {
await seite.goto("/vorlagen");
await seite.getByLabel("Name").fill(name);
await seite.getByRole("button", { name: "Vorlage anlegen" }).click();
await expect(seite.getByText(`„${name}“ wurde angelegt.`)).toBeVisible();
await seite.goto("/vorlagen");
await seite.getByRole("link", { name }).click();
await seite.getByRole("button", { name: /bearbeiten/i }).click();
await seite.waitForURL(/\/vorlagen\/version\//);
return seite.url();
}
async function aufraeumen(seite: Page, name: string) {
await seite.goto("/vorlagen");
const verweis = seite.getByRole("link", { name });
if ((await verweis.count()) === 0) return;
await verweis.click();
await seite.getByRole("button", { name: "archivieren" }).click();
await expect(seite.getByRole("button", { name: "wieder freigeben" })).toBeVisible();
}
test.describe("Vorlagen-Editor", () => {
test("legt an, baut auf, speichert und veröffentlicht", async ({ page }) => {
test.slow();
const name = `${NAME} A`;
await anmelden(page);
await entwurfAnlegen(page, name);
// Ein Abschnitt mit einem Befehlsschritt.
await page.getByRole("button", { name: "Abschnitt hinzufügen" }).click();
await page.getByLabel("Titel des Abschnitts").fill("Vorbereitung");
const leiste = page.locator("form.sticky");
await expect(leiste).toContainText("Nicht gespeicherte Änderungen");
await page.getByRole("button", { name: "Schritt hinzufügen" }).click();
await page.getByLabel("Titel", { exact: true }).last().fill("Rechner umbenennen");
await leiste.getByRole("button", { name: "Speichern" }).click();
await expect(leiste).toContainText("Entwurf gespeichert.");
await expect(leiste).toContainText("Alles gespeichert");
// Der Beweis, dass es nicht nur im Browser steht: neu laden.
await page.reload();
await expect(page.getByLabel("Titel des Abschnitts")).toHaveValue("Vorbereitung");
await expect(page.locator("form.sticky")).toContainText("Alles gespeichert");
// Veröffentlichen macht die Version unveränderlich.
await page.getByLabel("Änderungsnotiz (optional)").fill("Erste Fassung");
await page.getByRole("button", { name: "Veröffentlichen", exact: true }).click();
/*
* Die Erfolgsmeldung steht im Veroeffentlichen-Formular – und genau das ist
* nach dem `router.refresh()` verschwunden. Geprueft wird deshalb der
* Zustand danach, nicht die Meldung: das ist ohnehin die haerteren Zusage.
*/
await expect(page.locator("form.sticky")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Verschieben" })).toHaveCount(0);
await expect(page.getByText(/ist veröffentlicht und wird nicht mehr verändert/)).toBeVisible();
await aufraeumen(page, name);
});
test("warnt vor einem unbekannten Platzhalter, ohne zu blockieren", async ({ page }) => {
test.slow();
const name = `${NAME} B`;
await anmelden(page);
await entwurfAnlegen(page, name);
await page.getByRole("button", { name: "Abschnitt hinzufügen" }).click();
await page.getByLabel("Titel des Abschnitts").fill("Installation");
await page.getByRole("button", { name: "Schritt hinzufügen" }).click();
await page.getByLabel("Titel", { exact: true }).last().fill("{{hostnmae}} setzen");
await expect(page.getByText(/gehört zu keiner Variablen/)).toBeVisible();
await expect(page.getByText("{{hostnmae}}", { exact: true })).toBeVisible();
// Die Warnung ist eine Warnung: Speichern bleibt möglich.
const leiste = page.locator("form.sticky");
await leiste.getByRole("button", { name: "Speichern" }).click();
await expect(leiste).toContainText("Entwurf gespeichert.");
// Ein eingebauter Platzhalter loest keine Warnung aus.
await page.getByLabel("Titel", { exact: true }).last().fill("{{hostname}} setzen");
await expect(page.getByText(/gehört zu keiner Variablen/)).toHaveCount(0);
await aufraeumen(page, name);
});
test("sortiert am Handy mit dem Finger", async ({ page, isMobile, viewport }) => {
test.skip(!isMobile, "Nur im Projekt „handy“ – am Schreibtisch gibt es keinen Finger.");
test.slow();
const name = `${NAME} C`;
await anmelden(page);
await entwurfAnlegen(page, name);
for (const titel of ["Erster", "Zweiter"]) {
await page.getByRole("button", { name: "Abschnitt hinzufügen" }).click();
await page.getByLabel("Titel des Abschnitts").last().fill(titel);
}
const werte = async () =>
page
.getByLabel("Titel des Abschnitts")
.evaluateAll((felder) => felder.map((feld) => (feld as HTMLInputElement).value));
expect(await werte()).toEqual(["Erster", "Zweiter"]);
// Der Anfasser ist mindestens 44 px hoch – sonst trifft ihn niemand.
const anfasser = page.getByRole("button", { name: "Verschieben" }).first();
const kasten = await anfasser.boundingBox();
expect(kasten?.height ?? 0).toBeGreaterThanOrEqual(44);
expect(kasten?.width ?? 0).toBeGreaterThanOrEqual(44);
// Halten, ziehen, loslassen. Der TouchSensor wartet bewusst 200 ms, damit
// eine Wischgeste ein Bildlauf bleibt und kein Verschieben.
const x = kasten!.x + kasten!.width / 2;
const y = kasten!.y + kasten!.height / 2;
const sitzung = await page.context().newCDPSession(page);
await sitzung.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
await page.waitForTimeout(350);
for (let schritt = 1; schritt <= 12; schritt += 1) {
await sitzung.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x, y: y + schritt * 25 }],
});
await page.waitForTimeout(40);
}
await sitzung.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await expect.poll(werte).toEqual(["Zweiter", "Erster"]);
// Und dabei wird nicht horizontal gescrollt.
const masse = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
sichtbar: document.documentElement.clientWidth,
}));
expect(masse.scroll).toBeLessThanOrEqual(masse.sichtbar);
expect(viewport?.width).toBeLessThanOrEqual(430);
await aufraeumen(page, name);
});
});
+256
View File
@@ -0,0 +1,256 @@
import { describe, expect, it } from "vitest";
import {
BEISPIEL_PASSWORT,
SCHLUESSEL_REGEL,
abschnittNummer,
alsEditorAufbau,
alsStruktur,
beispielwerte,
doppelteSchluessel,
konfigFuerTyp,
mitBeispielwerten,
neueKennung,
schluesselFehler,
titelOhneNummer,
umsortieren,
verwendetePlatzhalter,
type EditorAufbau,
type EditorVariable,
} from "@/lib/editor-aufbau";
import { EINGEBAUTE_PLATZHALTER } from "@/lib/platzhalter";
import { StrukturSchema } from "@/lib/vorlagen-format";
/** Die reine Logik des Vorlagen-Editors (#010). Fuer Komponenten gibt es keine DOM-Umgebung. */
function variable(teil: Partial<EditorVariable> = {}): EditorVariable {
return {
kennung: neueKennung("variable"),
schluessel: "vorname",
beschriftung: "Vorname",
typ: "TEXT",
optionen: [],
pflicht: false,
...teil,
};
}
describe("umsortieren", () => {
it("schiebt einen Eintrag nach unten und laesst die Vorlage unberuehrt", () => {
const liste = ["a", "b", "c"];
expect(umsortieren(liste, 0, 2)).toEqual(["b", "c", "a"]);
expect(liste).toEqual(["a", "b", "c"]);
});
it("schiebt einen Eintrag nach oben", () => {
expect(umsortieren(["a", "b", "c"], 2, 1)).toEqual(["a", "c", "b"]);
});
it("gibt bei gleicher Position und ausserhalb der Liste dieselbe Liste zurueck", () => {
const liste = ["a", "b"];
expect(umsortieren(liste, 1, 1)).toBe(liste);
expect(umsortieren(liste, 0, 2)).toBe(liste);
expect(umsortieren(liste, -1, 0)).toBe(liste);
});
});
describe("Abschnittsnummer", () => {
it("nummeriert zweistellig ab eins", () => {
expect(abschnittNummer(0)).toBe("01");
expect(abschnittNummer(9)).toBe("10");
});
it("entfernt eine Nummer, die mit Mittelpunkt im Titel steht", () => {
expect(titelOhneNummer("01 · Vorbereitung")).toBe("Vorbereitung");
expect(titelOhneNummer("2·Konto")).toBe("Konto");
});
it("laesst eine Servicezeit in Ruhe — „24 · 7“ ist keine Nummerierung", () => {
// Gefunden im Review: die Regel griff auch, wenn hinter dem Mittelpunkt eine
// Ziffer stand. Aus „24 · 7 Bereitschaft“ wurde „7 Bereitschaft“, und beim
// naechsten Speichern stand das dauerhaft in der Datenbank.
expect(titelOhneNummer("24 · 7 Bereitschaft")).toBe("24 · 7 Bereitschaft");
expect(titelOhneNummer("08 · 17 Servicezeit")).toBe("08 · 17 Servicezeit");
});
it("laesst jeden anderen Trenner in Ruhe — „2. Etage“ ist eine Etage", () => {
expect(titelOhneNummer("2. Etage prüfen")).toBe("2. Etage prüfen");
expect(titelOhneNummer("02) Konto")).toBe("02) Konto");
expect(titelOhneNummer("3 - Abschluss")).toBe("3 - Abschluss");
expect(titelOhneNummer("10 Jahre Aufbewahrung")).toBe("10 Jahre Aufbewahrung");
expect(titelOhneNummer("Vorbereitung")).toBe("Vorbereitung");
});
});
describe("konfig beim Typwechsel", () => {
it("laesst fallen, was dem alten Typ gehoert", () => {
const konfig = konfigFuerTyp({ shell: "bash", befehl: "echo {{hostname}}" }, "HAKEN");
expect(konfig).toEqual({});
});
it("behaelt, was der neue Typ braucht", () => {
expect(konfigFuerTyp({ optionen: ["a", "b"], min: 1 }, "AUSWAHL")).toEqual({
optionen: ["a", "b"],
});
expect(konfigFuerTyp({ min: 1, max: 9, einheit: "GB", mehrzeilig: true }, "ZAHL")).toEqual({
min: 1,
max: 9,
einheit: "GB",
});
});
it("behaelt unbekannte Schluessel, damit ein Import aus einer spaeteren Fassung nichts verliert", () => {
expect(konfigFuerTyp({ befehl: "echo", aktionsname: "ad-anlegen" }, "HAKEN")).toEqual({
aktionsname: "ad-anlegen",
});
});
});
describe("Zustand und Struktur", () => {
const struktur = StrukturSchema.parse({
variablen: [
{ schluessel: "vorname", beschriftung: "Vorname", pflicht: true },
{ schluessel: "raum", beschriftung: "Raum", typ: "AUSWAHL", optionen: ["EG", "OG"] },
],
abschnitte: [
{
titel: "01 · Vorbereitung",
schritte: [
{ titel: "Gerät auspacken", pflicht: true },
{
titel: "Konto anlegen",
typ: "BEFEHL",
konfig: { shell: "powershell", befehl: "New-ADUser {{vorname}}" },
dokuUrl: "https://intern.example/ad",
},
],
},
],
});
it("uebernimmt die Struktur und gibt sie unveraendert zurueck — ohne die Nummer im Titel", () => {
const aufbau = alsEditorAufbau(struktur);
expect(aufbau.abschnitte[0]?.titel).toBe("Vorbereitung");
expect(aufbau.abschnitte[0]?.schritte).toHaveLength(2);
const zurueck = alsStruktur(aufbau);
expect(zurueck.abschnitte[0]?.titel).toBe("Vorbereitung");
expect(zurueck.abschnitte[0]?.schritte[1]?.konfig).toEqual({
shell: "powershell",
befehl: "New-ADUser {{vorname}}",
});
// Das Schema muss den Rueckweg annehmen, sonst scheitert erst das Speichern.
expect(StrukturSchema.safeParse(zurueck).success).toBe(true);
});
it("laesst leere Textfelder weg, statt sie als leere Zeichenkette zu schicken", () => {
const aufbau: EditorAufbau = {
variablen: [],
abschnitte: [
{
kennung: "a",
titel: "Vorbereitung",
schritte: [
{
kennung: "s",
titel: "Gerät auspacken",
hilfetext: " ",
typ: "HAKEN",
pflicht: false,
konfig: {},
dokuUrl: " https://intern.example/x ",
},
],
},
],
};
const schritt = alsStruktur(aufbau).abschnitte[0]?.schritte[0];
expect(schritt?.hilfetext).toBeUndefined();
expect(schritt?.dokuUrl).toBe("https://intern.example/x");
});
it("schickt Optionen nur fuer eine Auswahl-Variable mit", () => {
const aufbau: EditorAufbau = {
variablen: [variable({ typ: "TEXT", optionen: ["alt", "vergessen"] })],
abschnitte: [],
};
expect(alsStruktur(aufbau).variablen[0]?.optionen).toEqual([]);
});
it("findet die verwendeten Platzhalter", () => {
expect([...verwendetePlatzhalter(alsEditorAufbau(struktur))]).toEqual(["vorname"]);
});
});
describe("Variablenschluessel", () => {
it("nimmt gueltige Schluessel an", () => {
for (const schluessel of ["vorname", "ad_gruppe", "raum2"]) {
expect(schluesselFehler(schluessel), schluessel).toBeUndefined();
}
});
it("lehnt ab, was der Platzhalter nicht wiedererkennen koennte", () => {
for (const schluessel of ["Vorname", "mit-strich", "2te_wahl", ""]) {
expect(schluesselFehler(schluessel), schluessel).toBeDefined();
}
});
it("nennt die Regel als Satz, damit der Hinweis am Feld stehen kann", () => {
expect(SCHLUESSEL_REGEL).toContain("Kleinbuchstaben");
});
it("findet doppelte Schluessel und ignoriert leere", () => {
expect(
doppelteSchluessel([
variable({ schluessel: "vorname" }),
variable({ schluessel: "vorname" }),
variable({ schluessel: "" }),
variable({ schluessel: "" }),
]),
).toEqual(["vorname"]);
});
});
describe("Beispielwerte der Vorschau", () => {
const werte = beispielwerte([], "24.09.2026");
it("hat fuer jeden eingebauten Platzhalter einen Wert", () => {
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
expect(werte[platzhalter.schluessel], platzhalter.schluessel).toBeTruthy();
}
});
it("erzeugt fuer das generierte Passwort nie ein echtes, sondern immer denselben Beispieltext", () => {
expect(werte["generiertes_passwort"]).toBe(BEISPIEL_PASSWORT);
expect(beispielwerte([], "24.09.2026")["generiertes_passwort"]).toBe(BEISPIEL_PASSWORT);
});
it("nimmt das Datum von aussen, damit Server und Browser dasselbe rendern", () => {
expect(werte["datum"]).toBe("24.09.2026");
});
it("zieht fuer einen Schluessel, den es als Variable gibt, dessen Beispielwert vor", () => {
const eigene = beispielwerte(
[variable({ schluessel: "abteilung", typ: "AUSWAHL", optionen: ["IT", "Einkauf"] })],
"24.09.2026",
);
expect(eigene["abteilung"]).toBe("IT");
});
it("ersetzt Platzhalter auch mit Leerzeichen in den Klammern", () => {
expect(mitBeispielwerten("Hallo {{ vorname }} ({{sam}})", werte)).toBe("Hallo Anna (a.beispiel)");
});
it("laesst einen unbekannten Platzhalter stehen, so wie es der Lauf auch tut", () => {
expect(mitBeispielwerten("{{gibtsnicht}}", werte)).toBe("{{gibtsnicht}}");
});
it("setzt einen Wert mit $& woertlich ein", () => {
// Gefunden im Review: `String.replace` deutet im Ersatztext `$&`, `` $` ``,
// `$'` und `$1` als Rueckverweise. Ein Auswahlwert „Kosten $& Nutzen“ setzte
// den gefundenen Platzhalter wieder ein. In #012 baut derselbe Weg einen
// PowerShell-Befehl zusammen — dort waere es ein verfaelschter Befehl.
for (const wert of ["Kosten $& Nutzen", "vor $` danach", "A $' B", "Gruppe $1"]) {
expect(mitBeispielwerten("Raum: {{raum}}", { raum: wert }), wert).toBe(`Raum: ${wert}`);
}
});
});
+86
View File
@@ -0,0 +1,86 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { Markdown } from "@/components/markdown";
/**
* Hilfetexte als Markdown – und nichts darueber hinaus (#010).
*
* Ein Hilfetext wird von einem Redakteur geschrieben und von jedem Techniker
* geoeffnet. Rohes HTML darin waere fremder Code in fremden Browsern; ueber den
* JSON-Import kaeme er sogar von ausserhalb des Hauses. `react-markdown` laesst
* HTML von sich aus fallen — dieser Test haelt fest, dass niemand spaeter
* `rehype-raw` dazustellt, weil „der Kunde Tabellen in HTML schreibt".
*
* Gerendert wird mit `react-dom/server`, damit der Test ohne DOM-Umgebung laeuft.
*/
function alsHtml(text: string): string {
return renderToStaticMarkup(<Markdown text={text} />);
}
describe("Markdown im Hilfetext", () => {
it("rendert Fettschrift, Listen und Code", () => {
const html = alsHtml("**wichtig**\n\n- eins\n- zwei\n\n`Get-Help`");
expect(html).toContain("<strong>wichtig</strong>");
expect(html).toContain("<li>eins</li>");
expect(html).toContain("Get-Help");
});
it("rendert Tabellen aus GitHub-Markdown", () => {
const html = alsHtml("| Gerät | Port |\n|---|---|\n| Switch | 24 |");
expect(html).toContain("<table>");
expect(html).toContain("Switch");
});
it("laesst ein script-Element nicht durch", () => {
const html = alsHtml('Hinweis <script>alert("1")</script> Ende');
expect(html).not.toContain("<script>");
// Der Text bleibt sichtbar – maskiert, nicht heimlich verschluckt.
expect(html).toContain("&lt;script&gt;");
});
it("laesst weder Bilder mit Ereignisattribut noch eingebettete Rahmen durch", () => {
// Geprueft wird die *Auszeichnung*, nicht das Wort: „onerror" steht danach
// noch im Text, aber maskiert – es wird angezeigt und nicht ausgefuehrt.
const html = alsHtml('<img src=x onerror="alert(1)"> <iframe src="https://fremd.example"></iframe>');
expect(html).not.toContain("<img");
expect(html).not.toContain("<iframe");
expect(html).toContain("&lt;img");
});
it("oeffnet Links in einem neuen Tab ohne Herkunft", () => {
// Ein Klick mitten im Lauf darf den Lauf nicht verlassen, und die Zielseite
// muss nicht erfahren, aus welcher Installation der Klick kam.
const html = alsHtml("[Doku](https://intern.example/doku)");
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noreferrer"');
});
it("macht aus einem javascript-Link keinen klickbaren Verweis", () => {
// Markdown erlaubt `[Text](javascript:…)`. react-markdown filtert das
// Protokoll; faellt dieser Schutz weg, faellt es hier auf.
const html = alsHtml("[klick mich](javascript:alert(1))");
expect(html).not.toContain("javascript:alert");
});
it("gibt bei leerem Text nichts aus, ohne zu scheitern", () => {
expect(() => alsHtml("")).not.toThrow();
});
});
describe("Verweise und Bilder", () => {
it("macht aus einem Verweis ohne erlaubtes Protokoll keinen Verweis", () => {
// react-markdown leert den href. Ein <a href=""> laedt beim Klick die
// aktuelle Seite in einem neuen Tab – verwirrender als gar kein Verweis.
const html = renderToStaticMarkup(<Markdown text="[klick mich](javascript:alert(1))" />);
expect(html).not.toContain("<a");
expect(html).toContain("klick mich");
});
it("zeigt ein Bild als Alternativtext, statt es einzubinden", () => {
// Ein Bild laege auf einem fremden Host, und img-src erlaubt nur self.
const html = renderToStaticMarkup(<Markdown text="![Schrankplan](https://fremd.example/p.png)" />);
expect(html).not.toContain("<img");
expect(html).toContain("[Bild: Schrankplan]");
});
});
+15 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { navigationFuer } from "@/lib/navigation";
import { existsSync } from "node:fs";
import { NAVIGATION, navigationFuer } from "@/lib/navigation";
import { darf } from "@/server/auth/rollen";
import type { Rolle } from "@/generated/prisma/enums";
@@ -55,6 +56,19 @@ describe("Aufbau der Eintraege", () => {
}
});
it("hat zu jedem verfuegbaren Eintrag auch eine Seite", () => {
// Das Umstellen von `verfuegbar` ist ein Handgriff im Meilenstein, der die
// Seite liefert. Wird es zu frueh gemacht, zeigt die Navigation auf eine
// 404 – und genau das faellt beim Klicken selten auf, weil man die eigene
// neue Seite kennt und die anderen nicht anfasst.
for (const eintrag of NAVIGATION) {
if (!eintrag.verfuegbar) continue;
const teil = eintrag.pfad === "/" ? "" : eintrag.pfad;
const seite = new URL(`../src/app/(app)${teil}/page.tsx`, import.meta.url);
expect(existsSync(seite), `${eintrag.pfad} ist verfuegbar, hat aber keine page.tsx`).toBe(true);
}
});
it("hat keine doppelten Pfade je Modus", () => {
for (const modus of ["INTERN", "DIENSTLEISTUNG"] as const) {
const pfade = pfadeFuer(modus, "ADMIN");
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import {
EINGEBAUTE_PLATZHALTER,
QUELLEN_TEXT,
istEingebaut,
unbekanntePlatzhalter,
unbenutzteVariablen,
} from "@/lib/platzhalter";
import { platzhalterDerStruktur, VariableSchema, type Struktur } from "@/lib/vorlagen-format";
/**
* Die eingebauten Platzhalter (#010).
*
* Die Liste ist Daten, und Daten koennen still falsch sein: ein Schluessel mit
* Bindestrich wuerde im Lauf nie ersetzt, weil `platzhalterFinden` ihn nicht als
* Platzhalter erkennt. Deshalb wird die Liste hier gegen genau dieselbe Regel
* geprueft, die auch fuer Variablen gilt.
*/
describe("Liste der eingebauten Platzhalter", () => {
it("haelt sich an die Schluesselregel der Variablen", () => {
const schluesselRegel = VariableSchema.shape.schluessel;
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
const ergebnis = schluesselRegel.safeParse(platzhalter.schluessel);
expect(ergebnis.success, `${platzhalter.schluessel}: ${ergebnis.error?.message}`).toBe(true);
}
});
it("nennt jeden Schluessel nur einmal", () => {
const schluessel = EINGEBAUTE_PLATZHALTER.map((p) => p.schluessel);
expect(new Set(schluessel).size).toBe(schluessel.length);
});
it("erklaert jeden Platzhalter und ordnet ihn einer benannten Quelle zu", () => {
for (const platzhalter of EINGEBAUTE_PLATZHALTER) {
expect(platzhalter.erklaerung.trim().length, platzhalter.schluessel).toBeGreaterThan(0);
expect(QUELLEN_TEXT[platzhalter.quelle], platzhalter.schluessel).toBeTruthy();
}
});
it("enthaelt die im Auftrag genannten Platzhalter", () => {
// Namentlich, damit das Verschwinden eines einzelnen auffaellt – eine
// Vorlage, die ihn benutzt, bekaeme sonst plötzlich eine Warnung.
for (const schluessel of [
"hostname",
"seriennummer",
"inventarnummer",
"ticket",
"datum",
"techniker",
"kunde",
"kundenkuerzel",
"mitarbeiter",
"benutzer",
"vorname",
"nachname",
"upn",
"sam",
"mail",
"abteilung",
"titel",
"vorgesetzter",
"vorgesetzter_sam",
"dn",
"ou",
"gruppen",
"generiertes_passwort",
]) {
expect(istEingebaut(schluessel), schluessel).toBe(true);
}
});
it("kennzeichnet die Platzhalter, die nur im Modus Dienstleistung gefuellt sind", () => {
const nurDienstleistung = EINGEBAUTE_PLATZHALTER.filter((p) => p.nurDienstleistung).map(
(p) => p.schluessel,
);
expect(nurDienstleistung.sort()).toEqual(["kunde", "kundenkuerzel"]);
});
});
describe("Unbekannte Platzhalter", () => {
it("meldet, was weder eingebaut noch als Variable definiert ist", () => {
expect(unbekanntePlatzhalter(["hostname", "os_version", "tipfehler"], ["os_version"])).toEqual([
"tipfehler",
]);
});
it("meldet nichts, wenn alles bekannt ist", () => {
expect(unbekanntePlatzhalter(["hostname", "generiertes_passwort"], [])).toEqual([]);
});
it("findet einen Tippfehler in einem echten Aufbau", () => {
// Der praktische Fall: „{{hostnmae}}" im Befehl. Ohne Warnung merkt es erst
// der Techniker, wenn der Befehl den Platzhalter im Klartext enthaelt.
const struktur: Struktur = {
variablen: [
{
schluessel: "os_version",
beschriftung: "Betriebssystem",
typ: "TEXT",
optionen: [],
pflicht: false,
},
],
abschnitte: [
{
titel: "Installation",
schritte: [
{
titel: "Rechner umbenennen",
typ: "BEFEHL",
pflicht: true,
konfig: {
shell: "powershell",
befehl: 'Rename-Computer -NewName "{{hostnmae}}" # soll {{hostname}} sein',
},
},
{
titel: "{{os_version}} installieren",
hilfetext: "Danach {{ datum }} im Ticket vermerken.",
typ: "HAKEN",
pflicht: true,
konfig: {},
},
],
},
],
};
const verwendet = platzhalterDerStruktur(struktur);
expect(verwendet).toContain("hostnmae");
expect(
unbekanntePlatzhalter(
verwendet,
struktur.variablen.map((v) => v.schluessel),
),
).toEqual(["hostnmae"]);
});
});
describe("Unbenutzte Variablen", () => {
it("meldet eine Variable, die nirgends vorkommt", () => {
// Eine definierte Variable, die kein Schritt benutzt, fragt den Techniker
// beim Start nach etwas, das nie irgendwo auftaucht.
expect(unbenutzteVariablen(["hostname"], ["os_version", "join_typ"])).toEqual(["os_version", "join_typ"]);
});
it("meldet nichts, wenn jede Variable benutzt wird", () => {
expect(unbenutzteVariablen(["os_version"], ["os_version"])).toEqual([]);
});
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import { NextRequest } from "next/server";
import { istOeffentlich, proxy, sicherheitsrichtlinie } from "@/proxy";
/**
* Die Content-Security-Policy (#005, korrigiert in #010).
*
* Sie war in beiden Betriebsarten gleich streng — und genau deshalb hydrierte
* React im Entwicklungsmodus **nie**: `script-src` ohne `unsafe-eval` verbietet
* das, was React und Turbopack dort tun. Die Seiten sahen richtig aus, aber kein
* Knopf tat etwas, und ein Screenshot zeigte davon nichts. Gefunden erst, als der
* Editor aus #010 im Browser bedient werden sollte.
*
* Diese Tests halten beide Seiten fest: dass die Lockerung im Entwicklungsmodus
* da ist, und dass sie im ausgelieferten Stand fehlt.
*/
const NONCE = "abc123";
function teile(richtlinie: string): Map<string, string> {
return new Map(
richtlinie.split("; ").map((eintrag) => {
const [name, ...rest] = eintrag.split(" ");
return [name ?? "", rest.join(" ")];
}),
);
}
describe("Ausgelieferter Stand", () => {
const richtlinie = sicherheitsrichtlinie(NONCE, false);
it("erlaubt kein unsafe-eval", () => {
expect(richtlinie).not.toContain("unsafe-eval");
});
it("erlaubt keine WebSocket-Verbindungen", () => {
expect(teile(richtlinie).get("connect-src")).toBe("'self'");
});
it("bindet Skripte an die Nonce dieser Anfrage", () => {
expect(teile(richtlinie).get("script-src")).toBe(`'self' 'nonce-${NONCE}' 'strict-dynamic'`);
});
it("verbietet das Einbetten in fremde Seiten und Objekte", () => {
const werte = teile(richtlinie);
expect(werte.get("frame-ancestors")).toBe("'none'");
expect(werte.get("object-src")).toBe("'none'");
expect(werte.get("base-uri")).toBe("'self'");
expect(werte.get("form-action")).toBe("'self'");
});
it("erlaubt Bilder als data: und blob: – QR-Codes und Kamerabilder", () => {
expect(teile(richtlinie).get("img-src")).toBe("'self' data: blob:");
});
});
describe("Entwicklungsmodus", () => {
const richtlinie = sicherheitsrichtlinie(NONCE, true);
it("erlaubt unsafe-eval, sonst hydriert React nicht", () => {
expect(teile(richtlinie).get("script-src")).toContain("'unsafe-eval'");
});
it("erlaubt die WebSocket-Verbindung von Fast Refresh", () => {
expect(teile(richtlinie).get("connect-src")).toBe("'self' ws: wss:");
});
it("lockert sonst nichts", () => {
// Namentlich verglichen: was hier gleich bleiben soll, bleibt gleich.
const streng = teile(sicherheitsrichtlinie(NONCE, false));
const locker = teile(richtlinie);
for (const name of [...streng.keys()]) {
if (name === "script-src" || name === "connect-src") continue;
expect(locker.get(name), name).toBe(streng.get(name));
}
expect([...locker.keys()]).toEqual([...streng.keys()]);
});
});
describe("Oeffentliche Pfade", () => {
it("laesst Anmeldung, Auth-Rueckruf und Healthcheck ohne Cookie durch", () => {
for (const pfad of [
"/anmelden",
"/api/auth/oidc/start",
"/api/gesundheit",
"/manifest.webmanifest",
"/icon.svg",
]) {
expect(istOeffentlich(pfad), pfad).toBe(true);
}
});
it("laesst nichts anderes durch", () => {
for (const pfad of ["/", "/vorlagen", "/admin", "/api/laeufe/1/zustand", "/anmeldenX"]) {
expect(istOeffentlich(pfad), pfad).toBe(false);
}
});
});
describe("Die Entscheidung im Proxy selbst", () => {
/**
* Die beiden Tests oben pruefen die Funktion mit einem Argument, das der Test
* setzt — genau das hat der Review beanstandet: die eigentliche Entscheidung
* `process.env.NODE_ENV !== "production"` blieb ungeprueft. Wer dort `true`
* einsetzt, haette gruene Tests und lieferte `unsafe-eval` aus.
*
* Hier laeuft deshalb `proxy()` selbst, mit gesetztem NODE_ENV.
*/
function anfrageMitCookie(): NextRequest {
const anfrage = new NextRequest(new URL("http://127.0.0.1:3000/vorlagen"));
anfrage.cookies.set("lz_sitzung", "egal");
return anfrage;
}
function mitNodeEnv<T>(wert: string, was: () => T): T {
const vorher = process.env.NODE_ENV;
// NODE_ENV ist in den Typen schreibgeschuetzt, zur Laufzeit ist es eine
// gewoehnliche Variable – und genau die liest der Proxy.
const umgebung = process.env as Record<string, string | undefined>;
umgebung["NODE_ENV"] = wert;
try {
return was();
} finally {
umgebung["NODE_ENV"] = vorher;
}
}
it("liefert im gebauten Stand keine Lockerung aus", () => {
const richtlinie = mitNodeEnv("production", () =>
proxy(anfrageMitCookie()).headers.get("Content-Security-Policy"),
);
expect(richtlinie).toBeTruthy();
expect(richtlinie).not.toContain("unsafe-eval");
expect(richtlinie).toContain("connect-src 'self';");
expect(richtlinie).toContain("'strict-dynamic'");
});
it("lockert im Entwicklungsmodus", () => {
const richtlinie = mitNodeEnv("development", () =>
proxy(anfrageMitCookie()).headers.get("Content-Security-Policy"),
);
expect(richtlinie).toContain("'unsafe-eval'");
expect(richtlinie).toContain("ws: wss:");
});
it("gibt jeder Antwort eine eigene Nonce", () => {
// Eine wiederverwendete Nonce waere so gut wie keine.
const erste = proxy(anfrageMitCookie()).headers.get("Content-Security-Policy");
const zweite = proxy(anfrageMitCookie()).headers.get("Content-Security-Policy");
expect(erste).not.toBe(zweite);
});
it("schickt ohne Sitzungscookie auf die Anmeldeseite, mit dem Ziel im Gepäck", () => {
const antwort = proxy(new NextRequest(new URL("http://127.0.0.1:3000/vorlagen?suche=pc")));
expect(antwort.status).toBe(307);
const ziel = new URL(antwort.headers.get("location") ?? "");
expect(ziel.pathname).toBe("/anmelden");
expect(ziel.searchParams.get("ziel")).toBe("/vorlagen?suche=pc");
});
});
+27 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { z } from "@/lib/validierung";
import { AustauschSchema, StrukturSchema } from "@/lib/vorlagen-format";
import { AustauschSchema, StrukturSchema, stelleBenennen } from "@/lib/vorlagen-format";
/**
* Zod ist in src/lib/validierung.ts auf Deutsch eingestellt (#009).
@@ -32,3 +32,29 @@ describe("Deutsche Meldungen", () => {
expect(ergebnis.error?.issues[0]?.message).toMatch(/Unbekanntes Format/);
});
});
describe("Stelle benennen", () => {
it("macht aus einem Zod-Pfad eine Stelle im Editor", () => {
expect(stelleBenennen(["struktur", "abschnitte", 2, "schritte", 0, "titel"])).toBe(
"Abschnitt 03, Schritt 1: ",
);
expect(stelleBenennen(["variablen", 1, "schluessel"])).toBe("Variable 2: ");
expect(stelleBenennen(["abschnitte", 0, "schritte", 3, "konfig", "optionen", 1])).toBe(
"Abschnitt 01, Schritt 4, Option 2: ",
);
});
it("liefert nichts, wenn der Pfad keine Stelle beschreibt", () => {
// Dann bleibt die Meldung des Schemas fuer sich allein stehen – besser als
// ein leerer Doppelpunkt davor.
expect(stelleBenennen(["name"])).toBe("");
expect(stelleBenennen([])).toBe("");
});
it("nennt die Stelle in der Reihenfolge, in der man sie sucht", () => {
// Erst der Abschnitt, dann der Schritt darin – nicht umgekehrt.
const stelle = stelleBenennen(["abschnitte", 9, "schritte", 11, "titel"]);
expect(stelle.indexOf("Abschnitt")).toBeLessThan(stelle.indexOf("Schritt"));
expect(stelle).toBe("Abschnitt 10, Schritt 12: ");
});
});
+39
View File
@@ -3,6 +3,7 @@ import { Betriebsmodus } from "@/generated/prisma/enums";
import {
versionMitAufbau,
versionsHistorie,
vorlageHolen,
vorlagenFilterwerte,
vorlagenListe,
} from "@/server/queries/vorlagen";
@@ -153,4 +154,42 @@ describe.runIf(datenbankVerfuegbar)("Mandantensicht auf Vorlagen", () => {
expect(await vorlagenListe(techniker)).toHaveLength(3);
});
});
describe("Einzelne Vorlage", () => {
it("gibt eine fremde Vorlage nicht heraus", async () => {
// Die Vorlagenseite haengt allein an dieser Funktion. Gaebe sie die Zeile
// heraus, staenden Name, Beschreibung und Versionsgeschichte offen – die
// Kennung steht in jedem Link, den jemand weitergibt.
const { techniker, fremde, meine, globale } = await aufbau();
expect(await vorlageHolen(fremde.vorlage.id, techniker)).toBeUndefined();
expect(await vorlageHolen(meine.vorlage.id, techniker)).toBeDefined();
expect(await vorlageHolen(globale.vorlage.id, techniker)).toBeDefined();
});
it("gibt einem Administrator auch eine fremde heraus", async () => {
const { admin, fremde } = await aufbau();
expect(await vorlageHolen(fremde.vorlage.id, admin)).toBeDefined();
});
it("liefert dieselben Felder wie die Liste", async () => {
// Zwei Lesemodelle fuer dieselbe Zeile laufen auseinander; deshalb bauen
// beide aus derselben Auswahl. Der Test haelt das fest.
const { techniker, meine } = await aufbau();
const ausListe = (await vorlagenListe(techniker, { auchArchivierte: true })).find(
(zeile) => zeile.id === meine.vorlage.id,
);
expect(await vorlageHolen(meine.vorlage.id, techniker)).toEqual(ausListe);
});
it("findet auch eine archivierte Vorlage", async () => {
// Die Seite muss sie zeigen koennen, sonst liesse sich nichts freigeben.
const { techniker, meine } = await aufbau();
await testPrisma.vorlage.update({
where: { id: meine.vorlage.id },
data: { archiviert: true },
});
const zeile = await vorlageHolen(meine.vorlage.id, techniker);
expect(zeile?.archiviert).toBe(true);
});
});
});
+25 -2
View File
@@ -174,7 +174,11 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
).rejects.toThrow(/„upn“ gibt es zweimal/);
});
it("nennt bei einem Fehler im Aufbau den Pfad", async () => {
it("nennt die Stelle so, wie sie im Editor steht", async () => {
// „abschnitte.0.schritte.1.titel“ ist richtig und nutzlos: der Redakteur
// muesste von null an zaehlen. Der Review hat es als den Weg benannt, auf
// dem Arbeit wirklich verloren geht — mehrfach vergeblich speichern, dann
// weggehen.
const benutzer = await benutzerAnlegen();
const { version } = await vorlageAnlegen({ name: "Probe" }, benutzer.id);
await expect(
@@ -182,7 +186,7 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
variablen: [],
abschnitte: [{ titel: "Abschnitt", schritte: [{ titel: "gut" }, { titel: " " }] }],
}),
).rejects.toThrow(/abschnitte\.0\.schritte\.1\.titel/);
).rejects.toThrow("Abschnitt 01, Schritt 2: Ein Schritt braucht einen Titel.");
});
it("hinterlässt bei einem Fehler keinen halben Entwurf", async () => {
@@ -489,6 +493,25 @@ describe.runIf(datenbankVerfuegbar)("Vorlagen-Versionierung", () => {
expect(await testPrisma.protokoll.count({ where: { aktion: "Vorlage archiviert" } })).toBe(1);
});
it("lässt die Kundenzuordnung stehen, wenn sie nicht mitgeschickt wird", async () => {
// Im Modus Intern zeigt das Formular keine Kundenwahl. Wuerde ein
// fehlendes Feld als „auf leer setzen“ gelten, verlöre jede Vorlage beim
// Speichern ihrer Kopfdaten die Zuordnung – und nach einem Wechsel zurueck
// in den Modus Dienstleistung waere sie fuer alle sichtbar.
const benutzer = await benutzerAnlegen();
const kunde = await testPrisma.kunde.create({ data: { name: "Meiner", kuerzel: "MEIN" } });
const { vorlage } = await vorlageAnlegen({ name: "Probe", kundeId: kunde.id }, benutzer.id);
await vorlageBearbeiten(vorlage.id, { name: "Anders" }, benutzer.id);
expect((await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } })).kundeId).toBe(
kunde.id,
);
// Ausdrücklich `null` loest sie dagegen.
await vorlageBearbeiten(vorlage.id, { name: "Anders", kundeId: null }, benutzer.id);
expect((await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlage.id } })).kundeId).toBeNull();
});
it("schreibt nichts, wenn sich nichts ändert", async () => {
const benutzer = await benutzerAnlegen();
const { vorlage } = await veroeffentlichteVorlage(benutzer.id);
+48 -1
View File
@@ -143,16 +143,31 @@ describe("Rechtepruefung an den Einstiegspunkten", () => {
it("beginnt jede Server Action mit einer Rechtepruefung", () => {
// Strukturell geprueft, weil eine vergessene Pruefung sonst erst auffiele,
// wenn jemand sie ausnutzt. Die Rechtematrix selbst deckt tests/rollen.test.ts ab.
//
// Anerkannt wird auch ein Helfer **in derselben Datei**, der seinerseits
// `rechtErforderlich` ruft: seit #010 fangen die Vorlagen-Aktionen den
// RechteFehler ab und geben eine Meldung zurueck, statt die Seite
// auszuhaengen — im Editor waere sonst jede ungespeicherte Arbeit verloren.
// Die Pruefung findet trotzdem statt, nur eine Ebene tiefer.
const befunde: string[] = [];
for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) {
const quelle = readFileSync(join(verzeichnis, name), "utf8");
expect(quelle.startsWith('"use server";'), `${name} fehlt "use server"`).toBe(true);
// Helfer, die selbst pruefen – Name und Rumpf aus derselben Datei.
const helfer = [...quelle.matchAll(/(?:async )?function (\w+)\(([\s\S]*?)\n\}/g)]
.filter((treffer) => treffer[2]?.includes("rechtErforderlich("))
.map((treffer) => treffer[1])
.filter((funktion): funktion is string => funktion !== undefined);
for (const treffer of quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)) {
const [, funktion, rumpf] = treffer;
if (!funktion || OEFFENTLICHE_AKTIONEN.has(funktion)) continue;
if (!rumpf?.includes("rechtErforderlich(")) {
const prueft =
rumpf?.includes("rechtErforderlich(") === true ||
helfer.some((helfername) => rumpf?.includes(`${helfername}(`) === true);
if (!prueft) {
befunde.push(`${name}: ${funktion} prueft kein Recht`);
}
}
@@ -161,6 +176,38 @@ describe("Rechtepruefung an den Einstiegspunkten", () => {
expect(befunde).toEqual([]);
});
it("erkennt eine Aktion ohne jede Pruefung", () => {
// Der Test oben ist nachsichtiger geworden; dieser hier zeigt, dass er
// trotzdem noch anschlaegt. Geprueft wird die Regel, nicht die Datei.
const quelle = [
'"use server";',
"async function mitPruefung() {",
' await rechtErforderlich("vorlagenBearbeiten");',
"}",
"",
"export async function gut() {",
" await mitPruefung();",
"}",
"",
"export async function schlecht() {",
" return 42;",
"}",
].join("\n");
const helfer = [...quelle.matchAll(/(?:async )?function (\w+)\(([\s\S]*?)\n\}/g)]
.filter((treffer) => treffer[2]?.includes("rechtErforderlich("))
.map((treffer) => treffer[1]);
const ohnePruefung = [...quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)]
.filter(
([, , rumpf]) =>
rumpf?.includes("rechtErforderlich(") !== true &&
!helfer.some((name) => rumpf?.includes(`${name}(`) === true),
)
.map(([, funktion]) => funktion);
expect(ohnePruefung).toEqual(["schlecht"]);
});
it("findet ueberhaupt Aktionen, sonst prueft der Test oben nichts", () => {
const aktionen = readdirSync(verzeichnis)
.filter((n) => n.endsWith(".ts"))