Files
Laufzettel/tests/e2e/anhaenge.spec.ts
T
Florian WildeandClaude Opus 5 3cbd5026f8
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
feat(anhaenge): Fotos und Dateien an einem Schritt (#52)
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>
2026-10-04 23:05:00 +02:00

152 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});