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 { 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 = {}): Record { 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 { const kopie: Omit & { 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,", "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("gibt einer eingespielten Kopie keinen Seed-Schluessel", async () => { // Sonst hielte der Seed die Kopie fuer das Original und legte das Original // ein zweites Mal an (#021). Geprueft wird mit einem Dokument, das den // Schluessel ausdruecklich mitbringt - ein Angreifer oder ein Versehen // koennte ihn ja hineinschreiben. const redakteur = await redakteurAnlegen(); const quelle = await beispielVersionAnlegen(); const austausch = await vorlageExportieren(quelle); const { vorlageId } = await vorlageImportieren( { ...austausch, seedSchluessel: "pc-neuinstallation" }, redakteur.id, ); const kopie = await testPrisma.vorlage.findUniqueOrThrow({ where: { id: vorlageId } }); expect(kopie.seedSchluessel).toBeNull(); }); 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("Seed-Schluessel", () => { it("steht in keinem Export – auch nicht bei einer mitgelieferten Vorlage", async () => { // Ein Feld, das beim Einlesen verfaellt, gehoert nicht in die Datei: wer es // dort sieht, haelt es fuer uebertragbar. const quelle = await beispielVersionAnlegen(); await testPrisma.vorlage.updateMany({ data: { seedSchluessel: "pc-neuinstallation" } }); const austausch = await vorlageExportieren(quelle); expect(austausch).not.toHaveProperty("seedSchluessel"); expect(JSON.stringify(austausch)).not.toContain("pc-neuinstallation"); }); }); 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 { 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); }); }); });