Der Seed erkannte seine sieben Vorlagen am Namen. Wer eine umbenannte, bekam beim naechsten Lauf eine zweite -- und der Schnellstart stand auf der Geraeteseite doppelt. Vorlage.seedSchluessel traegt die Kennung, eindeutig wo gesetzt (partieller Index, weil eigene Vorlagen keinen tragen). Das Austauschformat kennt das Feld als seedSchluessel; nicht als schluessel, wie das Issue plante, denn so heisst dort schon das Feld einer Variablen. Der Rueckfall auf den Namen bleibt, sonst bekaeme eine Installation von vor diesem Feld beim ersten Lauf danach genau die Dubletten, die es verhindern soll. Wird sie so gefunden, traegt der Seed den Schluessel nach -- an der Entwicklungsdatenbank gefahren: sieben nachgetragen, nichts neu angelegt. Der Import setzt den Schluessel nicht, auch nicht wenn das Dokument ihn mitbringt, und der Export schreibt ihn nicht: eine eingespielte Kopie soll nicht den Platz des Originals einnehmen. Gegenprobe gefahren: ohne die Suche am Schluessel werden vier Tests rot. Closes #32
610 lines
24 KiB
TypeScript
610 lines
24 KiB
TypeScript
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("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<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);
|
||
});
|
||
});
|
||
});
|