feat(vorlagen): Editor, Liste und Versionsgeschichte (#24)
This commit was merged in pull request #24.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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("<script>");
|
||||
});
|
||||
|
||||
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("<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="" />);
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain("[Bild: Schrankplan]");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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: ");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user