import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Betriebsmodus, LaufStatus, VersionStatus } from "@/generated/prisma/enums"; import { dateinameSaeubern, gewaehlteDatei, groesseText, istBild, istErlaubterMedientyp, } from "@/lib/anhaenge"; import { AnhangFehler, anhaengeDesLaufs, anhangEntfernen, anhangLesen, anhangPfad, anhangSpeichern, } from "@/server/services/anhaenge"; import { MandantFehler } from "@/server/mandant"; import { LaufFehler } from "@/server/services/laeufe"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Anhaenge und Fotos an Schritten (#032). * * Vier Zusagen tragen das Ganze: der Archiv-Schreibschutz gilt auch hier, ein * fremder Kunde kommt nicht heran, die Grenzen greifen mit einer Begruendung, * und eine Datei ohne Zeile bleibt nicht liegen. * * Die Dateien landen in einem Wegwerf-Verzeichnis — `ANHAENGE_PFAD` wird fuer * diese Datei umgebogen und danach wieder weggeraeumt. */ const VORHER = process.env["ANHAENGE_PFAD"]; let verzeichnis = ""; beforeAll(async () => { verzeichnis = await mkdtemp(join(tmpdir(), "laufzettel-anhaenge-")); process.env["ANHAENGE_PFAD"] = verzeichnis; }); afterAll(async () => { if (VORHER === undefined) delete process.env["ANHAENGE_PFAD"]; else process.env["ANHAENGE_PFAD"] = VORHER; if (verzeichnis) await rm(verzeichnis, { recursive: true, force: true }); }); describe("Formen und Grenzen", () => { it("erlaubt Bilder und PDF, aber kein SVG", () => { // SVG ist ein Dokument mit Skripten, das ein Betrachter ausführt. Ein // Anhang, den jemand öffnet, darf kein Angriffsweg sein. expect(istErlaubterMedientyp("image/jpeg")).toBe(true); expect(istErlaubterMedientyp("application/pdf")).toBe(true); expect(istErlaubterMedientyp("image/svg+xml")).toBe(false); expect(istErlaubterMedientyp("text/html")).toBe(false); }); it("zeigt nur Bilder als Miniatur", () => { expect(istBild("image/png")).toBe(true); expect(istBild("application/pdf")).toBe(false); }); it("macht aus einem Pfad einen Dateinamen und wirft Steuerzeichen weg", () => { // Der Name wird nie zum Pfad – trotzdem landet er in einer Kopfzeile. expect(dateinameSaeubern("../../etc/passwd")).toBe("passwd"); expect(dateinameSaeubern("C:\\Users\\x\\foto.jpg")).toBe("foto.jpg"); expect(dateinameSaeubern('fo"to\u0000.jpg')).toBe("foto.jpg"); expect(dateinameSaeubern(" ")).toBe("anhang"); expect(dateinameSaeubern("x".repeat(300))).toHaveLength(200); }); it("schreibt Größen deutsch", () => { expect(groesseText(512)).toBe("512 B"); expect(groesseText(2048)).toBe("2 KB"); expect(groesseText(3 * 1024 * 1024 + 100_000)).toBe("3,1 MB"); }); it("nimmt aus zwei Feldern „datei“ das nicht leere", () => { /* * Der Schritt hat zwei Felder „datei“ – „Foto“ und „Datei“ – in einem * Formular, also kommt immer auch das leere mit. `FormData.get` lieferte * das erste, und wer „Datei“ wählte, bekam „Keine Datei gewählt“. Gefunden * in der Klickprobe am gebauten Stand; kein Dienst-Test schickt zwei * Felder. */ const leer = new File([], ""); const echt = new File([new Uint8Array([1, 2, 3])], "foto.png", { type: "image/png" }); expect(gewaehlteDatei([leer, echt])?.name).toBe("foto.png"); expect(gewaehlteDatei([echt, leer])?.name).toBe("foto.png"); expect(gewaehlteDatei([leer, leer])).toBeUndefined(); expect(gewaehlteDatei(["nicht einmal eine Datei", null])).toBeUndefined(); }); }); async function aufbau() { const kunde = await testPrisma.kunde.create({ data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true }, }); await testPrisma.einstellung.create({ data: { id: "global", modus: Betriebsmodus.INTERN } }); const admin = await testPrisma.benutzer.create({ data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" }, }); const vorlage = await testPrisma.vorlage.create({ data: { name: "PC Neuinstallation" } }); const version = await testPrisma.vorlagenVersion.create({ data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF }, }); const abschnitt = await testPrisma.vorlagenAbschnitt.create({ data: { versionId: version.id, titel: "Vorbereitung", position: 0 }, }); const schritt = await testPrisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Typenschild fotografieren", position: 0 }, }); await testPrisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() }, }); const lauf = await testPrisma.lauf.create({ data: { vorlagenVersionId: version.id, titel: "Ein Lauf", kundeId: kunde.id }, }); const ergebnis = await testPrisma.schrittergebnis.create({ data: { laufId: lauf.id, vorlagenSchrittId: schritt.id }, }); return { kunde, admin: { id: admin.id, rolle: admin.rolle }, laufId: lauf.id, schrittId: schritt.id, ergebnisId: ergebnis.id, }; } /** Ein winziges, gültiges PNG. */ const PNG = Buffer.from( "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" + "0000000a49444154789c6300010000050001" + "0d0a2db40000000049454e44ae426082", "hex", ); describe.runIf(datenbankVerfuegbar)("Anhänge", () => { beforeEach(async () => { await datenbankLeeren(); }); it("legt eine Datei ab und schreibt die Metadaten", async () => { const { admin, laufId, schrittId, ergebnisId, kunde } = await aufbau(); const { id } = await anhangSpeichern( { laufId, vorlagenSchrittId: schrittId, dateiname: "typenschild.png", medientyp: "image/png", inhalt: new Uint8Array(PNG), }, admin, ); const zeile = await testPrisma.anhang.findUniqueOrThrow({ where: { id } }); expect(zeile).toMatchObject({ schrittergebnisId: ergebnisId, laufId, kundeId: kunde.id, dateiname: "typenschild.png", medientyp: "image/png", groesse: PNG.byteLength, erstelltVonId: admin.id, }); // SHA-256 als Hex – eine CHECK-Bedingung hält die Form fest. expect(zeile.pruefsumme).toMatch(/^[0-9a-f]{64}$/); // Die Datei liegt wirklich da, und zwar unter einem Pfad aus der Kennung. const pfad = anhangPfad(zeile); expect(pfad).toContain(kunde.id); expect(pfad).toContain(laufId); expect(pfad).not.toContain("typenschild"); expect((await stat(pfad)).size).toBe(PNG.byteLength); expect(await readFile(pfad)).toEqual(PNG); }); it("liest den Inhalt wieder zurück", async () => { const { admin, laufId, schrittId } = await aufbau(); const { id } = await anhangSpeichern( { laufId, vorlagenSchrittId: schrittId, dateiname: "a.png", medientyp: "image/png", inhalt: new Uint8Array(PNG), }, admin, ); const datei = await anhangLesen(id, admin); expect(datei.inhalt).toEqual(PNG); expect(datei.medientyp).toBe("image/png"); expect(datei.dateiname).toBe("a.png"); }); it("weist einen fremden Medientyp mit Begründung ab", async () => { const { admin, laufId, schrittId } = await aufbau(); await expect( anhangSpeichern( { laufId, vorlagenSchrittId: schrittId, dateiname: "boese.svg", medientyp: "image/svg+xml", inhalt: new Uint8Array(PNG), }, admin, ), ).rejects.toThrow(/lassen sich nicht anhängen/); }); it("weist eine zu große Datei mit Größen in der Meldung ab", async () => { const vorher = process.env["ANHANG_MAX_MB"]; process.env["ANHANG_MAX_MB"] = "1"; try { const { admin, laufId, schrittId } = await aufbau(); await expect( anhangSpeichern( { laufId, vorlagenSchrittId: schrittId, dateiname: "gross.png", medientyp: "image/png", inhalt: new Uint8Array(2 * 1024 * 1024), }, admin, ), ).rejects.toThrow(/2,0 MB groß.*höchstens 1,0 MB/s); } finally { if (vorher === undefined) delete process.env["ANHANG_MAX_MB"]; else process.env["ANHANG_MAX_MB"] = vorher; } }); it("weist eine leere Datei ab", async () => { const { admin, laufId, schrittId } = await aufbau(); await expect( anhangSpeichern( { laufId, vorlagenSchrittId: schrittId, dateiname: "leer.png", medientyp: "image/png", inhalt: new Uint8Array(0), }, admin, ), ).rejects.toThrow(/leer/); }); it("begrenzt die Anzahl je Lauf", async () => { const vorher = process.env["ANHAENGE_JE_LAUF"]; process.env["ANHAENGE_JE_LAUF"] = "2"; try { const { admin, laufId, schrittId } = await aufbau(); const eingabe = { laufId, vorlagenSchrittId: schrittId, dateiname: "a.png", medientyp: "image/png", inhalt: new Uint8Array(PNG), }; await anhangSpeichern(eingabe, admin); await anhangSpeichern(eingabe, admin); await expect(anhangSpeichern(eingabe, admin)).rejects.toThrow(/Mehr als 2 je Lauf/); } finally { if (vorher === undefined) delete process.env["ANHAENGE_JE_LAUF"]; else process.env["ANHAENGE_JE_LAUF"] = vorher; } }); it("weist an einem archivierten Lauf ab – Hochladen und Entfernen", async () => { /* * Der Archiv-Schreibschutz sitzt an genau einer Stelle * (`laufZumSchreibenHolen` → `laufMussVeraenderbarSein`). Dass er auch hier * greift, ist das Kriterium aus dem Issue. */ const { admin, laufId, schrittId } = await aufbau(); const { id } = await anhangSpeichern( { laufId, vorlagenSchrittId: schrittId, dateiname: "a.png", medientyp: "image/png", inhalt: new Uint8Array(PNG), }, admin, ); // Eine CHECK-Bedingung verlangt zu einem archivierten Lauf auch einen // Abschlusszeitpunkt: archiviert wird nur, was fertig ist. await testPrisma.lauf.update({ where: { id: laufId }, data: { status: LaufStatus.ARCHIVIERT, gestartetAm: new Date(), abgeschlossenAm: new Date(), archiviertAm: new Date(), }, }); await expect( anhangSpeichern( { laufId, vorlagenSchrittId: schrittId, dateiname: "b.png", medientyp: "image/png", inhalt: new Uint8Array(PNG), }, admin, ), ).rejects.toThrow(LaufFehler); await expect(anhangEntfernen(id, admin)).rejects.toThrow(/schreibgeschützt/); // Lesen bleibt erlaubt: ein Foto vom Typenschild ist Teil des Belegs. expect((await anhangLesen(id, admin)).inhalt).toEqual(PNG); }); it("weist einen Schritt ab, der nicht zu diesem Lauf gehört", async () => { const { admin, laufId } = await aufbau(); await expect( anhangSpeichern( { laufId, vorlagenSchrittId: "gibt-es-nicht", dateiname: "a.png", medientyp: "image/png", inhalt: new Uint8Array(PNG), }, admin, ), ).rejects.toThrow(/Schritt gibt es in diesem Lauf nicht/); }); it("entfernt Zeile und Datei zusammen", async () => { const { admin, laufId, schrittId } = await aufbau(); const { id } = await anhangSpeichern( { laufId, vorlagenSchrittId: schrittId, dateiname: "a.png", medientyp: "image/png", inhalt: new Uint8Array(PNG), }, admin, ); const zeile = await testPrisma.anhang.findUniqueOrThrow({ where: { id } }); const pfad = anhangPfad(zeile); await anhangEntfernen(id, admin); expect(await testPrisma.anhang.findUnique({ where: { id } })).toBeNull(); await expect(stat(pfad)).rejects.toThrow(); }); it("sagt, wenn die Datei fehlt – und nennt die Sicherung als Grund", async () => { // Der Fall nach einer Sicherung, die nur die Datenbank mitgenommen hat. const { admin, laufId, schrittId } = await aufbau(); const { id } = await anhangSpeichern( { laufId, vorlagenSchrittId: schrittId, dateiname: "a.png", medientyp: "image/png", inhalt: new Uint8Array(PNG), }, admin, ); const zeile = await testPrisma.anhang.findUniqueOrThrow({ where: { id } }); await rm(anhangPfad(zeile)); await expect(anhangLesen(id, admin)).rejects.toThrow(/sicherung\.md/); }); it("zählt die Anhänge eines Laufs mit Urheber", async () => { const { admin, laufId, schrittId, ergebnisId } = await aufbau(); await anhangSpeichern( { laufId, vorlagenSchrittId: schrittId, dateiname: "eins.png", medientyp: "image/png", inhalt: new Uint8Array(PNG), }, admin, ); const liste = await anhaengeDesLaufs(laufId); expect(liste).toHaveLength(1); expect(liste[0]).toMatchObject({ schrittergebnisId: ergebnisId, dateiname: "eins.png", erstelltVon: "Admin", }); }); it("hält die Mandantengrenze in der Datenbank, nicht nur im Dienst", async () => { const { laufId, schrittId, ergebnisId } = await aufbau(); const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } }); expect(schrittId).toBeTruthy(); // Ein Anhang mit fremdem Kunden, an der Service-Schicht vorbei: der // zusammengesetzte Fremdschlüssel weist das ab. await expect( testPrisma.anhang.create({ data: { schrittergebnisId: ergebnisId, laufId, kundeId: fremd.id, dateiname: "x.png", medientyp: "image/png", groesse: 10, pruefsumme: "a".repeat(64), }, }), ).rejects.toThrow(/Anhang_lauf_gleicher_kunde/); }); it("weist eine unmögliche Größe und eine falsche Prüfsumme ab", async () => { const { laufId, ergebnisId, kunde } = await aufbau(); const basis = { schrittergebnisId: ergebnisId, laufId, kundeId: kunde.id, dateiname: "x.png", medientyp: "image/png", }; await expect( testPrisma.anhang.create({ data: { ...basis, groesse: 0, pruefsumme: "a".repeat(64) } }), ).rejects.toThrow(/Anhang_groesse_positiv/); await expect( testPrisma.anhang.create({ data: { ...basis, groesse: 10, pruefsumme: "keine pruefsumme" } }), ).rejects.toThrow(/Anhang_pruefsumme_form/); }); it("verweigert den Abruf für einen auf andere Kunden beschränkten Benutzer", async () => { const vorher = process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-"; try { const { admin, laufId, schrittId } = await aufbau(); await testPrisma.einstellung.update({ where: { id: "global" }, data: { modus: Betriebsmodus.DIENSTLEISTUNG }, }); const { id } = await anhangSpeichern( { laufId, vorlagenSchrittId: schrittId, dateiname: "a.png", medientyp: "image/png", inhalt: new Uint8Array(PNG), }, admin, ); const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } }); const techniker = await testPrisma.benutzer.create({ data: { email: "tech@firma.example", name: "Techniker", rolle: "TECHNIKER" }, }); await testPrisma.benutzerKunde.create({ data: { benutzerId: techniker.id, kundeId: fremd.id }, }); await expect(anhangLesen(id, { id: techniker.id, rolle: techniker.rolle })).rejects.toThrow( MandantFehler, ); } finally { if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher; } }); it("wirft bei einem Anhang, den es nicht gibt, einen eigenen Fehler", async () => { const { admin } = await aufbau(); await expect(anhangLesen("gibt-es-nicht", admin)).rejects.toThrow(AnhangFehler); await expect(anhangEntfernen("gibt-es-nicht", admin)).rejects.toThrow(AnhangFehler); }); });