import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { Betriebsmodus, VersionStatus } from "@/generated/prisma/enums"; import type { Rolle } from "@/generated/prisma/enums"; import { RechteFehler } from "@/server/auth/sitzungspflicht"; import { anhangSpeichern } from "@/server/services/anhaenge"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Die Adresse, die einen Anhang ausliefert (#032). * * **Warum eine eigene Datei, obwohl `tests/anhaenge.test.ts` schon den Dienst * prüft:** Das Akzeptanzkriterium von #032 lautet „ein Anhang eines fremden * Kunden ist **über seine Adresse** nicht abrufbar“. Geprüft war bis hierher nur * `anhangLesen` — der Dienst. Die ganze Rechteprüfung im Route Handler ließ sich * durch einen hartcodierten ADMIN ersetzen, ohne dass ein einziger Test rot * wurde (Reviewbefund zu #029). * * Das ist die teuerste Lücke, die eine Prüfung haben kann: dieser Handler ist * die **einzige** Stelle der Anwendung, die rohe Dateibytes ausliefert. Fällt * die Prüfung bei einem Umbau heraus, gibt jedes Foto jedem heraus, der eine * Kennung hat — und das Tor vor dem Commit bleibt grün. * * Gefahren wird deshalb der **Handler selbst**, wie bei den SCIM-Handlern und * beim Ereignisstrom. Nur die Sitzung wird gestellt; alles andere ist echt, bis * hinunter zur Datei auf der Platte. */ /* Der Handler wird echt gefahren; nur die Sitzung wird gestellt. */ vi.mock("@/server/auth/sitzungspflicht", async (original) => { const echt = await original(); return { ...echt, rechtErforderlich: vi.fn() }; }); const { rechtErforderlich } = await import("@/server/auth/sitzungspflicht"); const { GET } = await import("@/app/api/anhaenge/[id]/route"); const VORHER = process.env["ANHAENGE_PFAD"]; let ordner: string; beforeAll(async () => { ordner = await mkdtemp(join(tmpdir(), "laufzettel-anhang-route-")); process.env["ANHAENGE_PFAD"] = ordner; }); afterAll(async () => { if (VORHER === undefined) delete process.env["ANHAENGE_PFAD"]; else process.env["ANHAENGE_PFAD"] = VORHER; await rm(ordner, { recursive: true, force: true }); }); /** Ein winziges, gültiges PNG. */ const PNG = Buffer.from( "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" + "0000000a49444154789c6300010000050001" + "0d0a2db40000000049454e44ae426082", "hex", ); /** Ein winziges PDF – es wird anders ausgeliefert als ein Bild. */ const PDF = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n", "utf8"); 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 }, }); 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, }; } /** Legt einen Anhang an und gibt seine Kennung zurück. */ async function anhangAnlegen( laufId: string, schrittId: string, admin: { id: string; rolle: Rolle }, abweichend: { dateiname?: string; medientyp?: string; inhalt?: Buffer } = {}, ): Promise { const { id } = await anhangSpeichern( { laufId, vorlagenSchrittId: schrittId, dateiname: abweichend.dateiname ?? "typenschild.png", medientyp: abweichend.medientyp ?? "image/png", inhalt: new Uint8Array(abweichend.inhalt ?? PNG), }, admin, ); return id; } const kennung = (id: string) => ({ params: Promise.resolve({ id }) }); const anfrage = () => new Request("http://127.0.0.1:3300/api/anhaenge/egal"); describe.runIf(datenbankVerfuegbar)("Ein Anhang über seine Adresse", () => { beforeEach(async () => { await datenbankLeeren(); vi.mocked(rechtErforderlich).mockReset(); }); it("weist ohne Anmeldung ab, bevor eine Datei gelesen wird", async () => { const { admin, laufId, schrittId } = await aufbau(); const id = await anhangAnlegen(laufId, schrittId, admin); vi.mocked(rechtErforderlich).mockRejectedValue(new RechteFehler("Nicht angemeldet.")); const antwort = await GET(anfrage(), kennung(id)); expect(antwort.status).toBe(401); // Und kein Byte der Datei im Rumpf. expect(await antwort.text()).not.toContain("PNG"); }); it("weist auch ab, wenn die Rolle das Recht lesen nicht hat", async () => { /* * `MITARBEITER` hat seit #035 kein `lesen` — die Selbstauskunft ist dessen * **Ersatz** und kein Zusatz. Dass ein Anhang damit nicht erreichbar ist, * entscheidet `rechtErforderlich`; hier wird gezeigt, dass der Handler sich * daran hält und nicht selbst weiterläuft. */ const { admin, laufId, schrittId } = await aufbau(); const id = await anhangAnlegen(laufId, schrittId, admin); vi.mocked(rechtErforderlich).mockRejectedValue(new RechteFehler("Dafür fehlt dir die Berechtigung.")); expect((await GET(anfrage(), kennung(id))).status).toBe(401); }); it("liefert einem berechtigten Benutzer die Datei unverändert", async () => { const { admin, laufId, schrittId } = await aufbau(); const id = await anhangAnlegen(laufId, schrittId, admin); vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never); const antwort = await GET(anfrage(), kennung(id)); expect(antwort.status).toBe(200); expect(antwort.headers.get("content-type")).toBe("image/png"); expect(antwort.headers.get("content-length")).toBe(String(PNG.length)); // Byte für Byte dasselbe – ein Bild, das unterwegs umkodiert wird, ist kein Beweismittel. expect(Buffer.from(await antwort.arrayBuffer()).equals(PNG)).toBe(true); }); it("zeigt ein Bild inline und lädt ein PDF herunter", async () => { /* * Ein PDF kann Skripte enthalten. Ein Anhang, den jemand aus einem fremden * Lauf öffnet, soll nichts ausführen — deshalb `attachment` und * `nosniff`. */ const { admin, laufId, schrittId } = await aufbau(); vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never); const bild = await anhangAnlegen(laufId, schrittId, admin); const bildAntwort = await GET(anfrage(), kennung(bild)); expect(bildAntwort.headers.get("content-disposition")).toBe('inline; filename="typenschild.png"'); const pdf = await anhangAnlegen(laufId, schrittId, admin, { dateiname: "abnahme.pdf", medientyp: "application/pdf", inhalt: PDF, }); const pdfAntwort = await GET(anfrage(), kennung(pdf)); expect(pdfAntwort.headers.get("content-disposition")).toBe('attachment; filename="abnahme.pdf"'); expect(pdfAntwort.headers.get("x-content-type-options")).toBe("nosniff"); }); it("lässt keinen Zwischenspeicher die Datei weitergeben", async () => { // Ein Anhang gehört einem Kunden. Ein öffentlicher Cache gäbe ihn an den // nächsten Betrachter weiter, und hinter einem Reverse Proxy ist das real. const { admin, laufId, schrittId } = await aufbau(); const id = await anhangAnlegen(laufId, schrittId, admin); vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never); expect((await GET(anfrage(), kennung(id))).headers.get("cache-control")).toContain("private"); }); it("gibt den Anhang eines fremden Kunden nicht heraus – über seine Adresse", async () => { /* * **Das Akzeptanzkriterium von #032, zum ersten Mal an der Adresse * geprüft.** Der Techniker ist auf einen anderen Kunden beschränkt; die * Kennung des Anhangs kennt er. Ohne diese Prüfung reicht die Kennung. */ 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 anhangAnlegen(laufId, schrittId, admin); const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FRD" } }); const techniker = await testPrisma.benutzer.create({ data: { email: "tina@firma.example", name: "Tina", rolle: "TECHNIKER" }, }); await testPrisma.benutzerKunde.create({ data: { benutzerId: techniker.id, kundeId: fremd.id }, }); vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: { id: techniker.id, rolle: techniker.rolle }, } as never); const antwort = await GET(anfrage(), kennung(id)); /* * `404` und nicht `403`: nicht vorhanden und nicht sichtbar sind dieselbe * Antwort. Die Unterscheidung wäre schon eine Auskunft über fremde Kunden. */ expect(antwort.status).toBe(404); expect(antwort.headers.get("content-type")).toContain("application/json"); const daten = (await antwort.json()) as Record; // Und die Meldung nennt weder Dateiname noch Kunde. expect(JSON.stringify(daten)).not.toContain("typenschild"); expect(JSON.stringify(daten)).not.toContain("Eigene Organisation"); } finally { if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher; } }); it("antwortet auf eine Kennung, die es nicht gibt, mit 404", async () => { const { admin } = await aufbau(); vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never); const antwort = await GET(anfrage(), kennung("gibt-es-nicht")); expect(antwort.status).toBe(404); }); it("sagt bei fehlender Datei, dass die Zeile steht und die Datei fehlt", async () => { /* * Der Fall nach einer Sicherung, die nur die Datenbank mitgenommen hat. Die * Meldung muss das sagen und nicht „nicht gefunden“ behaupten — sonst sucht * der Betreiber an der falschen Stelle (#032, und siehe * `docs/betrieb/sicherung.md`). */ const { admin, laufId, schrittId } = await aufbau(); const id = await anhangAnlegen(laufId, schrittId, admin); await rm(ordner, { recursive: true, force: true }); vi.mocked(rechtErforderlich).mockResolvedValue({ benutzer: admin } as never); const antwort = await GET(anfrage(), kennung(id)); expect(antwort.status).toBe(404); expect(String((await antwort.json())["fehler"])).toMatch(/fehlt im Dateisystem/u); }); });