Files
Laufzettel/src/server/services/anhaenge.ts
T
Florian WildeandClaude Opus 5 75c335df23
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
test: Zusagen pruefbar machen, die nur von Hand hielten (#038, #037)
#038: Die E2E-Ablaeufe laufen ab jetzt vor jedem Merge nach main, nicht vor
jedem Commit. Gemessen: Bau 14 s, Start 7 s, Ablaeufe 256 s — zu den vier
Minuten des bestehenden Tores. Neun Minuten vor jedem Commit ist ein Tor, das
umgangen wird. Vor jedem Merge waeren es in Phase 2 acht Laeufe gewesen, und
jeder der vier Befunde aus #027 waere am Tag seiner Entstehung aufgefallen.

Geprueft wurde jeder der 16 Ablaeufe gegen die Frage, ob es ein
Vitest-Gegenstueck gibt. Neun Zusagen haengen ausschliesslich dort, und keine
laesst sich holen: alle brauchen einen echten Browser. Die Liste steht in
CLAUDE.md.

Der eigentliche Fund dabei war keine Testluecke, sondern eine Konvention ohne
Pruefer: "jede Seite prueft selbst" steht seit #007 in CLAUDE.md, und geprueft
hat es bis hierher nur ein Mensch, der nachsieht. Der Review von Phase 2 hat
alle 27 Seiten durchgesehen — ein Ergebnis, das fuer genau einen Tag gilt.
tests/zugang.test.ts liest sie jetzt, samt jedem Route Handler; die Ausnahmen
(Auth, Healthcheck) stehen namentlich mit Begruendung, und ein Test prueft auch
die Gegenrichtung.

#037: Das Lesemodell der Anhaenge liegt in queries/ und engt selbst ein — vorher
hing das daran, dass beide Aufrufer den Lauf vorher pruefen. /meine-geraete wird
bei 390 px gemessen, mit einem echten MITARBEITER-Konto aus dem E2E-Vorlauf,
dazu ein Test, dass dort kein Link in die Verwaltung angeboten wird.

Dabei selbst einen Fehler gemacht, der die neue Regel begruendet: fuer das
zweite Konto brauchte `anmelden` eine Variante, die nicht auf / wartet, und ich
habe das Warten fuer alle gelockert. Das machte zwei unverwandte Ablaeufe
unzuverlaessig — sichtbar nur im vollen Durchgang, einzeln waren beide gruen.
Ein gemeinsamer Helfer in einer Testsuite ist geteilter Zustand. Ohne vollen
Durchgang haette ich zwei rote Ablaeufe gemergt.

1457 Vitest-Tests (vorher 1454), 115 E2E-Ablaeufe (vorher 113). Offen bleibt nur
noch #024, und das verlangt einen echten Proxmox-Host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-11 06:05:29 +02:00

227 lines
7.3 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 { createHash } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { prisma } from "@/lib/db";
import {
ENDUNG_JE_TYP,
anhaengePfad,
dateinameSaeubern,
groesseText,
istErlaubterMedientyp,
maxGroesseBytes,
maxJeLauf,
type Medientyp,
} from "@/lib/anhaenge";
import { laufZumSchreibenHolen } from "@/server/services/laeufe";
import { kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
/**
* Anhaenge und Fotos an Schritten (#032).
*
* Die Datei liegt im Dateisystem, die Metadaten in der Datenbank. Der Pfad
* entsteht **aus der Kennung**, nie aus dem Namen vom Browser — ein Dateiname
* von aussen darf keinen Pfad bestimmen.
*
* Der Archiv-Schreibschutz gilt auch hier: `laufZumSchreibenHolen` sperrt die
* Laufzeile, prueft die Mandantengrenze und ruft `laufMussVeraenderbarSein` —
* dieselbe eine Stelle wie bei Schrittergebnissen und Kommentaren.
*/
export class AnhangFehler extends Error {
constructor(meldung: string) {
super(meldung);
this.name = "AnhangFehler";
}
}
/** Der Pfad einer Datei. Nach Kunde und Lauf gegliedert, damit ein Verzeichnis überschaubar bleibt. */
function dateiPfad(anhang: {
readonly id: string;
readonly kundeId: string;
readonly laufId: string;
readonly medientyp: string;
}): string {
const endung = istErlaubterMedientyp(anhang.medientyp) ? ENDUNG_JE_TYP[anhang.medientyp] : "bin";
return join(anhaengePfad(), anhang.kundeId, anhang.laufId, `${anhang.id}.${endung}`);
}
export type AnhangEingabe = {
readonly laufId: string;
readonly vorlagenSchrittId: string;
readonly dateiname: string;
readonly medientyp: string;
readonly inhalt: Uint8Array;
};
/**
* Legt einen Anhang an einem Schritt ab.
*
* Reihenfolge mit Absicht: erst alles pruefen, dann die Datei schreiben, dann
* die Zeile anlegen. Bricht das Anlegen der Zeile ab, wird die Datei wieder
* entfernt — eine Datei ohne Zeile waere Muell, den niemand findet. Umgekehrt
* waere es schlimmer: eine Zeile ohne Datei ist ein Anhang, der beim Oeffnen
* fehlschlaegt.
*/
export async function anhangSpeichern(
eingabe: AnhangEingabe,
benutzer: HandelnderBenutzer,
): Promise<{ readonly id: string }> {
if (!istErlaubterMedientyp(eingabe.medientyp)) {
throw new AnhangFehler(
`Dateien vom Typ „${eingabe.medientyp || "unbekannt"}“ lassen sich nicht anhängen. ` +
"Erlaubt sind Bilder (JPEG, PNG, WebP, HEIC) und PDF.",
);
}
if (eingabe.inhalt.byteLength === 0) {
throw new AnhangFehler("Die Datei ist leer.");
}
const grenze = maxGroesseBytes();
if (eingabe.inhalt.byteLength > grenze) {
throw new AnhangFehler(
`Die Datei ist ${groesseText(eingabe.inhalt.byteLength)} groß. Erlaubt sind höchstens ` +
`${groesseText(grenze)} je Datei.`,
);
}
const vorbereitet = await prisma.$transaction(async (tx) => {
// Sperrt den Lauf, prueft Mandant **und** Schreibschutz – die eine Stelle.
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
const ergebnis = await tx.schrittergebnis.findUnique({
where: {
laufId_vorlagenSchrittId: {
laufId: lauf.id,
vorlagenSchrittId: eingabe.vorlagenSchrittId,
},
},
select: { id: true },
});
if (!ergebnis) {
throw new AnhangFehler("Diesen Schritt gibt es in diesem Lauf nicht.");
}
const vorhanden = await tx.anhang.count({ where: { laufId: lauf.id } });
const hoechstens = maxJeLauf();
if (vorhanden >= hoechstens) {
throw new AnhangFehler(
`An diesem Lauf hängen schon ${vorhanden} Dateien. Mehr als ${hoechstens} je Lauf ` +
"nimmt Laufzettel nicht — erst eine entfernen.",
);
}
return { laufId: lauf.id, kundeId: lauf.kundeId, schrittergebnisId: ergebnis.id };
});
const pruefsumme = createHash("sha256").update(eingabe.inhalt).digest("hex");
const id = `anh_${createHash("sha256")
.update(`${vorbereitet.schrittergebnisId}:${pruefsumme}:${Date.now()}:${Math.random()}`)
.digest("hex")
.slice(0, 24)}`;
const pfad = dateiPfad({
id,
kundeId: vorbereitet.kundeId,
laufId: vorbereitet.laufId,
medientyp: eingabe.medientyp,
});
await mkdir(join(pfad, ".."), { recursive: true });
await writeFile(pfad, eingabe.inhalt, { flag: "wx" });
try {
await prisma.anhang.create({
data: {
id,
schrittergebnisId: vorbereitet.schrittergebnisId,
laufId: vorbereitet.laufId,
kundeId: vorbereitet.kundeId,
dateiname: dateinameSaeubern(eingabe.dateiname),
medientyp: eingabe.medientyp,
groesse: eingabe.inhalt.byteLength,
pruefsumme,
erstelltVonId: benutzer.id,
},
});
} catch (fehler) {
// Die Datei wieder weg: eine Datei ohne Zeile findet niemand mehr.
await rm(pfad, { force: true });
throw fehler;
}
return { id };
}
/**
* Den Inhalt eines Anhangs holen – mit Recht und Mandantengrenze.
*
* **Jeder** Abruf prueft beides, auch der des Bildes selbst. Eine Adresse, die
* ohne Pruefung ausliefert, waere die Luecke, die alles andere umgeht.
*/
export async function anhangLesen(
anhangId: string,
benutzer: HandelnderBenutzer,
): Promise<{
readonly inhalt: Buffer;
readonly medientyp: string;
readonly dateiname: string;
readonly groesse: number;
}> {
const anhang = await prisma.anhang.findUnique({ where: { id: anhangId } });
if (!anhang) throw new AnhangFehler("Diesen Anhang gibt es nicht (mehr).");
await kundeZugriffPruefen(anhang.kundeId, benutzer);
const pfad = dateiPfad(anhang);
let inhalt: Buffer;
try {
inhalt = await readFile(pfad);
} catch {
/*
* Die Zeile steht, die Datei fehlt. Das ist der Fall nach einer Sicherung,
* die nur die Datenbank mitgenommen hat — und deshalb sagt die Meldung das
* auch, statt „nicht gefunden“ zu behaupten.
*/
throw new AnhangFehler(
`Die Datei zu „${anhang.dateiname}“ fehlt im Dateisystem. Wurde eine Sicherung ` +
"eingespielt, die nur die Datenbank enthielt? Siehe docs/betrieb/sicherung.md.",
);
}
return {
inhalt,
medientyp: anhang.medientyp,
dateiname: anhang.dateiname,
groesse: anhang.groesse,
};
}
/**
* Entfernt einen Anhang.
*
* Erst die Zeile, dann die Datei: bleibt die Datei liegen, ist sie unerreichbar
* und beim naechsten Aufraeumen zu finden. Bliebe umgekehrt die Zeile stehen,
* zeigte die Oberflaeche einen Anhang, der nicht mehr zu oeffnen ist.
*/
export async function anhangEntfernen(anhangId: string, benutzer: HandelnderBenutzer): Promise<void> {
const anhang = await prisma.anhang.findUnique({ where: { id: anhangId } });
if (!anhang) throw new AnhangFehler("Diesen Anhang gibt es nicht (mehr).");
await prisma.$transaction(async (tx) => {
// Sperrt, prueft Mandant und Schreibschutz – an einem archivierten Lauf
// kommt kein Anhang weg.
await laufZumSchreibenHolen(tx, anhang.laufId, benutzer);
await tx.anhang.delete({ where: { id: anhangId } });
});
await rm(dateiPfad(anhang), { force: true });
}
/** Nur fuer Tests und Werkzeuge: der Pfad einer Zeile. */
export function anhangPfad(anhang: {
readonly id: string;
readonly kundeId: string;
readonly laufId: string;
readonly medientyp: string;
}): string {
return dateiPfad(anhang);
}
export type { Medientyp };