Files
Laufzettel/tests/vorlagen-austausch.test.ts
T
florian.wilde d8d5444e9f
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch (#23)
2026-09-25 06:10:01 +00:00

579 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it } from "vitest";
import { GRENZEN, type Austausch } from "@/lib/vorlagen-format";
import {
AustauschFehler,
vorlageExportieren,
vorlageImportieren,
} from "@/server/services/vorlagen-austausch";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/**
* JSON-Austausch fuer Vorlagen (#009).
*
* Der Rundlauf ist der eigentliche Prueffall: was exportiert und wieder
* eingespielt wird, muss dasselbe sein. Alles andere hier haelt fest, was dabei
* bewusst *nicht* mitwandert (Kennungen, Positionen, Kundenbezug) und was ein
* Import mit einer krummen Datei tun soll.
*/
/** Ein Befehl mit Platzhaltern – er darf den Rundlauf Zeichen fuer Zeichen ueberleben. */
const BEFEHL = 'New-ADUser -Name "{{vorname}} {{nachname}}" -Department "{{abteilung}}"';
/** Urheber der Protokolleintraege; ohne echten Benutzer greift der Fremdschluessel. */
async function redakteurAnlegen() {
return testPrisma.benutzer.create({
data: { email: "redaktion@firma.example", name: "Redaktion", rolle: "REDAKTEUR" },
});
}
/**
* Eine veroeffentlichte Version mit zwei Abschnitten, Schritten verschiedener
* Typen und zwei Variablen.
*
* Die Zeilen werden absichtlich in verkehrter Reihenfolge angelegt: dann zeigt
* der Export, dass er nach `position` sortiert und nicht danach, wie die
* Datenbank die Zeilen gerade zurueckgibt.
*/
async function beispielVersionAnlegen(): Promise<string> {
const vorlage = await testPrisma.vorlage.create({
data: {
name: "Onboarding Notebook",
beschreibung: "Neue Person, neues Gerät.",
kategorie: "Onboarding",
schlagworte: ["notebook", "eintritt"],
},
});
// Erst als Entwurf anlegen und befuellen, dann veroeffentlichen: an einer
// veroeffentlichten Version laesst sich nichts mehr anhaengen, dafuer sorgen
// Trigger in der Datenbank.
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 3, status: "ENTWURF" },
});
await testPrisma.vorlagenVariable.create({
data: {
versionId: version.id,
schluessel: "abteilung",
beschriftung: "Abteilung",
typ: "AUSWAHL",
optionen: ["IT", "Vertrieb"],
pflicht: false,
position: 1,
},
});
await testPrisma.vorlagenVariable.create({
data: {
versionId: version.id,
schluessel: "vorname",
beschriftung: "Vorname",
typ: "TEXT",
pflicht: true,
position: 0,
},
});
const zweiter = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "02 · Konto", position: 1 },
});
const erster = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "01 · Vorbereitung", position: 0 },
});
await testPrisma.vorlagenSchritt.createMany({
data: [
{
abschnittId: erster.id,
titel: "Gerät auspacken",
typ: "HAKEN",
pflicht: true,
position: 0,
},
{
abschnittId: erster.id,
titel: "Seriennummer erfassen",
hilfetext: "Aufkleber auf der Unterseite.",
typ: "TEXT",
pflicht: true,
konfig: { mehrzeilig: false },
dokuUrl: "https://intern.example/seriennummern",
position: 1,
},
{
abschnittId: erster.id,
titel: "Arbeitsspeicher prüfen",
typ: "ZAHL",
konfig: { min: 8, max: 64, einheit: "GB" },
position: 2,
},
],
});
await testPrisma.vorlagenSchritt.createMany({
data: [
{
abschnittId: zweiter.id,
titel: "Benutzerkonto anlegen",
typ: "BEFEHL",
pflicht: true,
// Ein unbekannter Schluessel: das Schema ist nachsichtig, damit ein
// Export aus einer spaeteren Fassung hier nichts verliert.
konfig: { shell: "powershell", befehl: BEFEHL, kuenftigesFeld: { tief: [1, 2] } },
position: 0,
},
{
abschnittId: zweiter.id,
titel: "Eintrittsdatum hinterlegen",
typ: "DATUM",
position: 1,
},
],
});
await testPrisma.vorlagenVersion.update({
where: { id: version.id },
data: { status: "VEROEFFENTLICHT", veroeffentlichtAm: new Date() },
});
return version.id;
}
/** Das kleinste Dokument, das durchgeht – Ausgangspunkt fuer die Fehlerfaelle. */
function dokument(aenderung: Record<string, unknown> = {}): Record<string, unknown> {
return {
format: 1,
name: "Kleine Vorlage",
struktur: {
variablen: [{ schluessel: "vorname", beschriftung: "Vorname" }],
abschnitte: [{ titel: "01 · Start", schritte: [{ titel: "Anfangen" }] }],
},
...aenderung,
};
}
/** Der Rundlauf darf sich in `version` unterscheiden – alles andere nicht. */
function ohneVersion(austausch: Austausch): Omit<Austausch, "version"> {
const kopie: Omit<Austausch, "version"> & { version?: number } = { ...austausch };
delete kopie.version;
return kopie;
}
/**
* Diese Faelle scheitern am Schema, also noch vor dem ersten Datenbankzugriff –
* deshalb laufen sie auch ohne Testdatenbank.
*/
describe("Import: Pruefung des Dokuments", () => {
it("weist ein fehlendes oder falsches Format ab", async () => {
const ohneFormat = dokument();
delete ohneFormat["format"];
await expect(vorlageImportieren(ohneFormat, "wer-auch-immer")).rejects.toThrow(AustauschFehler);
await expect(vorlageImportieren(dokument({ format: 2 }), "wer")).rejects.toThrow(/Format 1/);
await expect(vorlageImportieren("kein Objekt", "wer")).rejects.toThrow(AustauschFehler);
});
it("nennt bei einem leeren Schritt-Titel den Pfad", async () => {
const kaputt = dokument({
struktur: {
variablen: [],
abschnitte: [
{ titel: "01 · Start", schritte: [{ titel: "Anfangen" }] },
{
titel: "02 · Konto",
schritte: [{ titel: "Erledigen" }, { titel: "Prüfen" }, { titel: " " }],
},
],
},
});
await expect(vorlageImportieren(kaputt, "wer")).rejects.toThrow(
"struktur.abschnitte.1.schritte.2.titel: Ein Schritt braucht einen Titel.",
);
});
it("weist doppelte Variablenschluessel ab und nennt den Schluessel", async () => {
const doppelt = dokument({
struktur: {
variablen: [
{ schluessel: "vorname", beschriftung: "Vorname" },
{ schluessel: "vorname", beschriftung: "Rufname" },
],
abschnitte: [],
},
});
await expect(vorlageImportieren(doppelt, "wer")).rejects.toThrow(/„vorname“ gibt es zweimal/);
});
it("weist ungueltige Variablenschluessel ab", async () => {
// Der Schluessel steht als {{schluessel}} in Texten; ein Grossbuchstabe oder
// ein Strich liesse sich dort nicht sauber wiedererkennen.
for (const schluessel of ["Vorname", "mit-strich", "", "1vorname", "mit punkt"]) {
const falsch = dokument({
struktur: { variablen: [{ schluessel, beschriftung: "Egal" }], abschnitte: [] },
});
await expect(vorlageImportieren(falsch, "wer")).rejects.toThrow(AustauschFehler);
}
});
it("weist einen Doku-Link ab, der kein http ist", async () => {
// Der Doku-Link landet in der Abarbeitungsansicht als `href`. Ein
// `javascript:`-Link darin waere fremder Code im Browser des Technikers –
// eingespielt ueber eine Datei, die ein Kunde geschickt hat.
for (const link of [
"javascript:alert(1)",
"JavaScript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"intern.example/doku",
]) {
const kaputt = dokument({
struktur: {
variablen: [],
abschnitte: [{ titel: "Start", schritte: [{ titel: "Anfangen", dokuUrl: link }] }],
},
});
await expect(vorlageImportieren(kaputt, "wer"), link).rejects.toThrow(/http/);
}
});
it("nimmt http und https an", async () => {
for (const link of ["http://intern.example/doku", "https://intern.example/doku"]) {
const gut = dokument({
struktur: {
variablen: [],
abschnitte: [{ titel: "Start", schritte: [{ titel: "Anfangen", dokuUrl: link }] }],
},
});
// Ohne Datenbank kommt es bis zum Schreiben; abgewiesen wird es nicht.
await expect(vorlageImportieren(gut, "wer")).rejects.not.toThrow(/http/);
}
});
it("weist ein Dokument ab, das die Mengengrenzen sprengt", async () => {
// Eine Datei mit 100 000 Schritten ist kein Import, sondern ein
// Erschoepfungsangriff auf die Datenbank.
const zuViele = dokument({
struktur: {
variablen: [],
abschnitte: Array.from({ length: GRENZEN.abschnitte + 1 }, (_, i) => ({
titel: `Abschnitt ${i}`,
schritte: [],
})),
},
});
await expect(vorlageImportieren(zuViele, "wer")).rejects.toThrow(
new RegExp(`${GRENZEN.abschnitte} Abschnitte`),
);
const zuLang = dokument({
struktur: {
variablen: [],
abschnitte: [{ titel: "Start", schritte: [{ titel: "x".repeat(GRENZEN.text + 1) }] }],
},
});
await expect(vorlageImportieren(zuLang, "wer")).rejects.toThrow(AustauschFehler);
});
it("weist einen unbekannten Schritt-Typ ab", async () => {
// Ein unbekannter Typ wuerde in der Abarbeitung zu einem Schritt ohne
// Eingabefeld – der Techniker koennte ihn nie erledigen.
const kaputt = dokument({
struktur: {
variablen: [],
abschnitte: [{ titel: "Start", schritte: [{ titel: "Anfangen", typ: "AKTION" }] }],
},
});
await expect(vorlageImportieren(kaputt, "wer")).rejects.toThrow(AustauschFehler);
});
it("nennt bei einem ungueltigen Schluessel den Pfad und die Regel", async () => {
const falsch = dokument({
struktur: { variablen: [{ schluessel: "Vorname", beschriftung: "Egal" }], abschnitte: [] },
});
await expect(vorlageImportieren(falsch, "wer")).rejects.toThrow(
/^struktur\.variablen\.0\.schluessel: Ein Variablenschlüssel beginnt/,
);
});
});
describe.runIf(datenbankVerfuegbar)("Vorlagen-Austausch", () => {
beforeEach(async () => {
await datenbankLeeren();
});
describe("Export", () => {
it("liefert Abschnitte, Schritte und Variablen in der Reihenfolge der Position", async () => {
const versionId = await beispielVersionAnlegen();
const austausch = await vorlageExportieren(versionId);
expect(austausch.format).toBe(1);
expect(austausch.name).toBe("Onboarding Notebook");
expect(austausch.kategorie).toBe("Onboarding");
expect(austausch.schlagworte).toEqual(["notebook", "eintritt"]);
expect(austausch.version).toBe(3);
expect(austausch.struktur.variablen.map((v) => v.schluessel)).toEqual(["vorname", "abteilung"]);
expect(austausch.struktur.abschnitte.map((a) => a.titel)).toEqual(["01 · Vorbereitung", "02 · Konto"]);
expect(austausch.struktur.abschnitte[0]?.schritte.map((s) => s.titel)).toEqual([
"Gerät auspacken",
"Seriennummer erfassen",
"Arbeitsspeicher prüfen",
]);
expect(austausch.struktur.abschnitte[1]?.schritte.map((s) => s.typ)).toEqual(["BEFEHL", "DATUM"]);
});
it("enthaelt keine Kennungen, Positionen und keinen Kundenbezug", async () => {
const versionId = await beispielVersionAnlegen();
const austausch = await vorlageExportieren(versionId);
const rohtext = JSON.stringify(austausch);
expect(rohtext).not.toContain(versionId);
for (const feld of ['"id"', "versionId", "abschnittId", "position", "kundeId", "erstelltAm"]) {
expect(rohtext).not.toContain(feld);
}
});
it("wirft bei einer unbekannten Version", async () => {
await expect(vorlageExportieren("gibt-es-nicht")).rejects.toThrow(AustauschFehler);
});
});
describe("Import", () => {
it("legt eine neue Vorlage mit Version 1 im Status ENTWURF an", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const austausch = await vorlageExportieren(quelle);
const { vorlageId, versionId } = await vorlageImportieren(austausch, redakteur.id);
// Die Quelle bleibt unberuehrt, es gibt jetzt zwei Vorlagen.
expect(await testPrisma.vorlage.count()).toBe(2);
const version = await testPrisma.vorlagenVersion.findUniqueOrThrow({
where: { id: versionId },
});
expect(version.vorlageId).toBe(vorlageId);
expect(version.version).toBe(1);
expect(version.status).toBe("ENTWURF");
expect(version.veroeffentlichtAm).toBeNull();
expect(await testPrisma.vorlagenVersion.count({ where: { vorlageId } })).toBe(1);
});
it("leitet die Positionen aus der Reihenfolge der Listen ab", async () => {
const redakteur = await redakteurAnlegen();
const { versionId } = await vorlageImportieren(
dokument({
struktur: {
variablen: [
{ schluessel: "vorname", beschriftung: "Vorname" },
{ schluessel: "nachname", beschriftung: "Nachname" },
],
abschnitte: [
{ titel: "01 · Start", schritte: [{ titel: "Eins" }, { titel: "Zwei" }] },
{ titel: "02 · Ende", schritte: [{ titel: "Drei" }] },
],
},
}),
redakteur.id,
);
const variablen = await testPrisma.vorlagenVariable.findMany({
where: { versionId },
orderBy: { position: "asc" },
});
expect(variablen.map((v) => [v.schluessel, v.position])).toEqual([
["vorname", 0],
["nachname", 1],
]);
const abschnitte = await testPrisma.vorlagenAbschnitt.findMany({
where: { versionId },
orderBy: { position: "asc" },
include: { schritte: { orderBy: { position: "asc" } } },
});
expect(abschnitte.map((a) => a.position)).toEqual([0, 1]);
expect(abschnitte[0]?.schritte.map((s) => [s.titel, s.position])).toEqual([
["Eins", 0],
["Zwei", 1],
]);
expect(abschnitte[1]?.schritte.map((s) => s.position)).toEqual([0]);
});
it("nimmt einen Namen, den es schon gibt, ohne Murren an", async () => {
const redakteur = await redakteurAnlegen();
await vorlageImportieren(dokument(), redakteur.id);
await vorlageImportieren(dokument(), redakteur.id);
const gleichnamige = await testPrisma.vorlage.findMany({ where: { name: "Kleine Vorlage" } });
expect(gleichnamige).toHaveLength(2);
// Kein angehaengtes "(2)": der Name kommt so an, wie er im Dokument steht.
expect(gleichnamige.map((v) => v.name)).toEqual(["Kleine Vorlage", "Kleine Vorlage"]);
});
it("schreibt einen Protokolleintrag", async () => {
const redakteur = await redakteurAnlegen();
const { vorlageId } = await vorlageImportieren(dokument(), redakteur.id);
const eintraege = await testPrisma.protokoll.findMany({
where: { entitaet: "Vorlage", entitaetId: vorlageId },
});
expect(eintraege).toHaveLength(1);
expect(eintraege[0]?.aktion).toBe("Vorlage importiert");
expect(eintraege[0]?.benutzerId).toBe(redakteur.id);
});
it("hinterlaesst nach einem Fehlschlag keine halbe Vorlage", async () => {
// Ein unbekannter Benutzer laesst den Protokolleintrag am Fremdschluessel
// scheitern – also erst, nachdem Vorlage, Version und Schritte geschrieben
// sind. Genau dieser Fall zeigt, dass die Transaktion greift.
await expect(vorlageImportieren(dokument(), "gibt-es-nicht")).rejects.toThrow();
expect(await testPrisma.vorlage.count()).toBe(0);
expect(await testPrisma.vorlagenVersion.count()).toBe(0);
expect(await testPrisma.vorlagenAbschnitt.count()).toBe(0);
expect(await testPrisma.vorlagenSchritt.count()).toBe(0);
expect(await testPrisma.vorlagenVariable.count()).toBe(0);
expect(await testPrisma.protokoll.count()).toBe(0);
});
it("schreibt nichts, wenn das Dokument abgewiesen wird", async () => {
await expect(vorlageImportieren(dokument({ format: 99 }), "wer")).rejects.toThrow(AustauschFehler);
expect(await testPrisma.vorlage.count()).toBe(0);
});
});
describe("Rundlauf", () => {
it("ergibt ein inhaltsgleiches Dokument – bis auf die Version", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const erst = await vorlageExportieren(quelle);
const { versionId } = await vorlageImportieren(erst, redakteur.id);
const wieder = await vorlageExportieren(versionId);
expect(ohneVersion(wieder)).toEqual(ohneVersion(erst));
// Die Quelle stand auf Version 3, die eingespielte Vorlage faengt bei 1 an.
expect(erst.version).toBe(3);
expect(wieder.version).toBe(1);
});
it("laesst einen BEFEHL-Schritt samt Platzhaltern unveraendert", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[1]?.schritte[0];
expect(schritt?.typ).toBe("BEFEHL");
expect(schritt?.konfig["shell"]).toBe("powershell");
expect(schritt?.konfig["befehl"]).toBe(BEFEHL);
});
it("bewahrt unbekannte konfig-Schluessel", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[1]?.schritte[0];
expect(schritt?.konfig["kuenftigesFeld"]).toEqual({ tief: [1, 2] });
});
it("bewahrt Hilfetext, Doku-Link und Zahlengrenzen", async () => {
const redakteur = await redakteurAnlegen();
const quelle = await beispielVersionAnlegen();
const { versionId } = await vorlageImportieren(await vorlageExportieren(quelle), redakteur.id);
const schritte = (await vorlageExportieren(versionId)).struktur.abschnitte[0]?.schritte;
expect(schritte?.[1]?.hilfetext).toBe("Aufkleber auf der Unterseite.");
expect(schritte?.[1]?.dokuUrl).toBe("https://intern.example/seriennummern");
expect(schritte?.[2]?.konfig).toEqual({ min: 8, max: 64, einheit: "GB" });
});
});
describe("Export unsauberer Daten", () => {
/** Legt einen einzelnen Schritt mit frei gewaehltem `konfig` an. */
async function schrittMitKonfig(konfig: unknown): Promise<string> {
const vorlage = await testPrisma.vorlage.create({ data: { name: "Krumm" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Abschnitt" },
});
await testPrisma.vorlagenSchritt.create({
data: {
abschnittId: abschnitt.id,
titel: "Schritt",
typ: "BEFEHL",
konfig: konfig as never,
},
});
return version.id;
}
it("verliert bei einer unbekannten Shell nicht den Befehl", async () => {
// Das war ein echter Befund: `safeParse` verwarf bei jeder Beanstandung
// das ganze `konfig` – eine Shell aus einer spaeteren Fassung kostete
// lautlos auch den Befehl. Genau die Datei, die jemand zum Umzug braucht.
const versionId = await schrittMitKonfig({ shell: "zsh", befehl: "echo hallo" });
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[0]?.schritte[0];
expect(schritt?.konfig["befehl"]).toBe("echo hallo");
expect(schritt?.konfig["shell"]).toBe("zsh");
});
it("liefert für konfig-Werte, die gar kein Objekt sind, ein leeres Objekt", async () => {
for (const krumm of ["kaputt", 42, [1, 2], true]) {
const versionId = await schrittMitKonfig(krumm);
const schritt = (await vorlageExportieren(versionId)).struktur.abschnitte[0]?.schritte[0];
expect(schritt?.konfig, JSON.stringify(krumm)).toEqual({});
await datenbankLeeren();
}
});
it("lässt sich mit unbekanntem konfig-Schlüssel wieder einspielen", async () => {
// Der Export muss nicht nur gelingen, er muss auch durch die Pruefung des
// Imports kommen – sonst waere er eine Datei, die niemand nutzen kann.
const redakteur = await redakteurAnlegen();
const versionId = await schrittMitKonfig({ shell: "bash", befehl: "echo", tief: null });
const { versionId: neu } = await vorlageImportieren(await vorlageExportieren(versionId), redakteur.id);
const schritt = (await vorlageExportieren(neu)).struktur.abschnitte[0]?.schritte[0];
expect(schritt?.konfig["befehl"]).toBe("echo");
expect(schritt?.konfig["tief"]).toBeNull();
});
it("weist eine unbekannte Shell beim Einspielen laut ab", async () => {
// Hier laufen zwei Regeln absichtlich auseinander, und das ist die
// Entscheidung: der **Export** gibt eine unbekannte Shell heraus, damit die
// Datei nichts verliert; der **Import** weist sie ab, weil `shell` darueber
// entscheidet, wie ein Wert maskiert wird. Eine stillschweigend verworfene
// Shell hiesse, einen bash-Befehl nach PowerShell-Regeln zu maskieren – das
// kann auf einem Server Daten kosten. Die Meldung nennt Pfad und erlaubte
// Werte, also kann der Redakteur die Datei richtigstellen.
const redakteur = await redakteurAnlegen();
const versionId = await schrittMitKonfig({ shell: "zsh", befehl: "echo hallo" });
const dokument = await vorlageExportieren(versionId);
await expect(vorlageImportieren(dokument, redakteur.id)).rejects.toThrow(/schritte\.0\.konfig\.shell/);
await expect(vorlageImportieren(dokument, redakteur.id)).rejects.toThrow(/powershell/);
// Nur die eine Quellvorlage steht da, keine halb eingespielte zweite.
expect(await testPrisma.vorlage.count()).toBe(1);
});
it("weist einen bereits gespeicherten javascript-Link beim Wiedereinspielen ab", async () => {
// Aeltere Zeilen koennten so einen Link tragen, denn die Pruefung kam erst
// mit #009. Der Export gibt ihn heraus, der Import faengt ihn – die Datei
// ist dann ein sichtbarer Fehlerfall und kein stiller Durchlaeufer.
const redakteur = await redakteurAnlegen();
const vorlage = await testPrisma.vorlage.create({ data: { name: "Alt" } });
const version = await testPrisma.vorlagenVersion.create({
data: { vorlageId: vorlage.id, version: 1, status: "ENTWURF" },
});
const abschnitt = await testPrisma.vorlagenAbschnitt.create({
data: { versionId: version.id, titel: "Abschnitt" },
});
await testPrisma.vorlagenSchritt.create({
data: { abschnittId: abschnitt.id, titel: "Schritt", dokuUrl: "javascript:alert(1)" },
});
const dokument = await vorlageExportieren(version.id);
expect(dokument.struktur.abschnitte[0]?.schritte[0]?.dokuUrl).toBe("javascript:alert(1)");
await expect(vorlageImportieren(dokument, redakteur.id)).rejects.toThrow(/http/);
expect(await testPrisma.vorlage.count()).toBe(1);
});
});
});