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

This commit was merged in pull request #24.
This commit is contained in:
2026-09-25 08:23:22 +00:00
parent d8d5444e9f
commit e3d80e5982
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"))