feat(anhaenge): Fotos und Dateien an einem Schritt (#52) (#59)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #59.
This commit is contained in:
2026-10-05 20:49:26 +00:00
parent 855164066c
commit 3ccf3aa2d9
25 changed files with 1989 additions and 47 deletions
+486
View File
@@ -0,0 +1,486 @@
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);
});
});
+26
View File
@@ -49,6 +49,32 @@ describe("Betriebsstand: docker-compose.yml", () => {
expect(probe).toEqual(["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/gesundheit"]);
});
it("hat ein Volume für die Anhänge – sonst sind sie beim nächsten Neuaufbau weg", () => {
/*
* Die Dateien der Anhaenge liegen im Dateisystem, in der Datenbank stehen
* nur die Metadaten (#032). Ohne Volume lebten sie im Container: ein
* `docker compose up --build` waere Datenverlust, und die Zeilen zeigten auf
* Dateien, die es nicht mehr gibt.
*/
const web = stand.services["web"] as unknown as {
volumes?: string[];
environment?: Record<string, string>;
};
expect(web.volumes).toContain("anhaenge:/daten/anhaenge");
expect(web.environment?.["ANHAENGE_PFAD"]).toBe("/daten/anhaenge");
expect(Object.keys((stand as unknown as { volumes: Record<string, unknown> }).volumes)).toContain(
"anhaenge",
);
});
it("nennt das Anhang-Volume in der Sicherungsanleitung", () => {
// Eine Sicherung, die nur `pg_dump` macht, verliert ab #032 Daten. Dass die
// Anleitung das sagt, ist Teil der Zusage – nicht nur das Volume selbst.
const sicherung = readFileSync("docs/betrieb/sicherung.md", "utf8");
expect(sicherung).toContain("anhaenge");
expect(sicherung).toMatch(/ANHAENGE_PFAD/);
});
it("bricht ohne Geheimnisse ab, statt mit Standardwerten zu starten", () => {
const roh = readFileSync("docker-compose.yml", "utf8");
expect(roh).toContain("${APP_SECRET:?");
+10 -2
View File
@@ -23,10 +23,16 @@ async function namen(abfrage: string): Promise<string[]> {
describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
it("hat die zusammengesetzten Fremdschlüssel der Mandantengrenze", async () => {
// Ohne sie kann ein Lauf ein Gerät eines fremden Kunden ziehen.
// Ohne sie kann ein Lauf ein Gerät eines fremden Kunden ziehen — und ein
// Anhang an einem Schritt hängen, der zu einem anderen Lauf gehört.
expect(
await namen(`SELECT conname AS name FROM pg_constraint WHERE conname LIKE '%gleicher_kunde'`),
await namen(
`SELECT conname AS name FROM pg_constraint
WHERE conname LIKE '%gleicher_kunde' OR conname LIKE '%gleicher_lauf'`,
),
).toEqual([
"Anhang_ergebnis_gleicher_lauf",
"Anhang_lauf_gleicher_kunde",
"Geraet_besitzer_gleicher_kunde",
"Lauf_abteilung_gleicher_kunde",
"Lauf_geraet_gleicher_kunde",
@@ -80,6 +86,8 @@ describe.runIf(datenbankVerfuegbar)("Handgeschriebene Datenbankobjekte", () => {
);
// Namentlich aufgezählt, damit auch das Verschwinden einer einzelnen auffällt.
expect(vorhanden).toEqual([
"Anhang_groesse_positiv",
"Anhang_pruefsumme_form",
"Einstellung_einzelne_zeile",
"Geraet_registriert_braucht_namen",
"Geraet_tagcode_form",
+151
View File
@@ -0,0 +1,151 @@
import { expect, test } from "@playwright/test";
import "dotenv/config";
import { createHash } from "node:crypto";
import { stat } from "node:fs/promises";
import { prisma } from "@/lib/db";
import { anhangPfad } from "@/server/services/anhaenge";
import { SEED_TAG, SEED_VORLAGE, anmelden, kundeWaehlen, variablenFuellen, vorlageWaehlen } from "./helfer";
/**
* Fotos und Dateien an einem Schritt, durch die Oberflaeche (#032).
*
* Dieser Ablauf gibt es, weil der Dienst-Test ihn nicht ersetzen konnte. Der
* Schritt hat **zwei** Felder `datei` in einem Formular — „Foto“ und „Datei“ —,
* und `FormData.get` lieferte das erste, also das leere: wer „Datei“ waehlte,
* bekam „Keine Datei gewählt“, waehrend „Foto“ ging. Neunzehn gruene
* Dienst-Tests sahen davon nichts, weil keiner zwei Felder schickt. Gefunden hat
* es die Klickprobe am gebauten Stand; festgehalten wird es hier.
*
* Geprueft wird deshalb bewusst **ueber den zweiten Knopf** und danach, ob es
* wirklich steht: Neuladen, Zeile in der Datenbank, Datei auf der Platte, und
* die Pruefsumme der ausgelieferten Bytes gegen die gespeicherte.
*/
const STEMPEL = Date.now();
/* Ein echtes 2x2-PNG. Eine leere Datei wuerde der Dienst zu Recht abweisen. */
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAACZgbYnAAAAEUlEQVR4nGP8z8DAwMDEwAAAEAAHAAo7/UkAAAAASUVORK5CYII=",
"base64",
);
/* Der zweite Ablauf arbeitet am Lauf des ersten weiter – also der Reihe nach. */
test.describe.serial("Anhänge an einem Schritt", () => {
let laufId = "";
test("über „Datei“ hochladen, neu laden, entfernen", async ({ page }) => {
test.slow();
await anmelden(page);
await page.goto("/laeufe/starten");
await vorlageWaehlen(page, SEED_VORLAGE);
const geraet = await prisma.geraet.findUniqueOrThrow({
where: { tagCode: SEED_TAG },
select: { id: true, kundeId: true },
});
await kundeWaehlen(page, geraet.kundeId);
await page.selectOption('select[name="geraetId"]', geraet.id);
await page.locator("details summary", { hasText: "Titel, Ticket" }).click();
await page.getByLabel("Titel", { exact: true }).fill(`ZZ E2E Anhang ${STEMPEL}`);
await variablenFuellen(page);
await page.getByRole("button", { name: "Lauf anlegen" }).click();
await page.waitForURL(/\/laeufe\/(?!starten)[^/]+$/, { timeout: 30_000 });
laufId = new URL(page.url()).pathname.split("/").pop() ?? "";
/*
* Beginnen, wie der Techniker es taete. Anhaengen ginge auch am offenen
* Lauf — `lesend` haengt am Archiv und am Recht, nicht am Status —, aber
* genau so wird die Oberflaeche benutzt.
*/
await page.getByRole("button", { name: "Lauf beginnen" }).click();
await expect(page.getByRole("button", { name: "Lauf abschließen" })).toBeVisible();
for (const kopf of await page.locator("button[aria-expanded]").all()) {
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
}
/*
* Feld 0 ist „Foto“, Feld 1 ist „Datei“ – beide heissen `datei` und stehen
* im selben Formular. Genau der zweite Weg war kaputt, also geht der Ablauf
* ueber ihn.
*/
const felder = page.locator('input[name="datei"]');
expect(await felder.count()).toBeGreaterThan(1);
await felder.nth(1).setInputFiles({ name: "serverraum.png", mimeType: "image/png", buffer: PNG });
const miniatur = page.locator('img[src^="/api/anhaenge/"]').first();
await expect(miniatur).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("serverraum.png").first()).toBeVisible();
// Neu laden: steht es wirklich, oder nur im Zustand der Seite?
await page.reload();
await expect(page.locator('img[src^="/api/anhaenge/"]').first()).toBeVisible({ timeout: 30_000 });
const zeile = await prisma.anhang.findFirstOrThrow({
where: { laufId },
select: {
id: true,
kundeId: true,
laufId: true,
dateiname: true,
medientyp: true,
groesse: true,
pruefsumme: true,
},
});
expect(zeile).toMatchObject({
dateiname: "serverraum.png",
medientyp: "image/png",
groesse: PNG.byteLength,
pruefsumme: createHash("sha256").update(PNG).digest("hex"),
});
// Und auf der Platte, unter dem Pfad aus der Kennung.
const pfad = anhangPfad(zeile);
expect((await stat(pfad)).size).toBe(PNG.byteLength);
// Die Auslieferung prueft Recht und Mandant selbst – und liefert die Bytes,
// die hereingingen.
const antwort = await page.request.get(`/api/anhaenge/${zeile.id}`);
expect(antwort.status()).toBe(200);
expect(antwort.headers()["content-type"]).toBe("image/png");
expect(antwort.headers()["x-content-type-options"]).toBe("nosniff");
expect(
createHash("sha256")
.update(await antwort.body())
.digest("hex"),
).toBe(zeile.pruefsumme);
// Entfernen nimmt Zeile **und** Datei.
await page.getByRole("button", { name: "serverraum.png entfernen" }).click();
await expect(page.locator('img[src^="/api/anhaenge/"]')).toHaveCount(0, { timeout: 30_000 });
expect(await prisma.anhang.count({ where: { laufId } })).toBe(0);
await expect(stat(pfad)).rejects.toThrow();
});
test("weist ein SVG ab, auch wenn es am Dateiwähler vorbei kommt", async ({ page }) => {
test.slow();
await anmelden(page);
expect(laufId).not.toBe("");
await page.goto(`/laeufe/${laufId}`);
for (const kopf of await page.locator("button[aria-expanded]").all()) {
if ((await kopf.getAttribute("aria-expanded")) === "false") await kopf.click();
}
await page
.locator('input[name="datei"]')
.nth(1)
.setInputFiles({
name: "boeses.svg",
mimeType: "image/svg+xml",
buffer: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'),
});
/*
* `accept` haelt im Dateiwaehler nur die Finger ab. Dass ein SVG nicht
* hereinkommt, muss der Server sagen – ein SVG ist ein Dokument mit
* Skripten, das ein Betrachter ausfuehrt.
*/
await expect(page.getByText(/lassen sich nicht anhängen/)).toBeVisible({ timeout: 30_000 });
expect(await prisma.anhang.count({ where: { laufId } })).toBe(0);
});
});