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

Ein Schritt nimmt jetzt Bilder (JPEG, PNG, WebP, HEIC) und PDF auf, am
Handy direkt aus der Kamera. Die Dateien liegen im Dateisystem unter
ANHAENGE_PFAD, nach Kunde und Lauf gegliedert; in der Datenbank stehen
nur Name, Typ, Groesse und Pruefsumme. Der Pfad entsteht aus der
Kennung, nie aus dem Dateinamen vom Browser.

Jeder Abruf prueft Recht und Mandantengrenze — auch der des Bildes
selbst: der Route Handler ruft rechtErforderlich() selbst, weil die
Huelle unter (app) Route Handler nicht abdeckt. Hochgeladen wird ueber
eine Server Action statt ueber einen zweiten Endpunkt; der
Archiv-Schreibschutz haengt an derselben einen Stelle wie alles andere
am Lauf. Kein SVG: ein SVG ist ein Dokument, das Skripte ausfuehrt.

In der Datenbank halten zwei CHECK-Bedingungen und zwei
zusammengesetzte Fremdschluessel fest, was auch gilt, wenn jemand an
der Service-Schicht vorbei schreibt — ein Anhang kann strukturell nicht
an einem Schritt eines anderen Laufs oder bei einem fremden Kunden
haengen.

docs/betrieb/sicherung.md ist neu geschrieben: dort stand "Kein
Upload-Verzeichnis, keine erzeugten Dateien", und eine Sicherung nach
der alten Anleitung haette ab hier still Daten verloren. Sicherung und
Ruecksicherung sind einmal wirklich gefahren, mit Pruefsummenvergleich.

Gefunden in der Klickprobe am gebauten Stand und nur dort: zwei Felder
"datei" in einem Formular, und FormData.get nahm das leere. Wer "Datei"
waehlte, bekam "Keine Datei gewaehlt", waehrend "Foto" ging. Die Regel
liegt jetzt als reine Funktion in src/lib, und tests/e2e/anhaenge.spec.ts
laedt bewusst ueber den zweiten Knopf hoch.

Closes #52

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-04 23:05:00 +02:00
co-authored by Claude Opus 5
parent 855164066c
commit 3cbd5026f8
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);
});
});