Files
Laufzettel/tests/anhang-route.test.ts
T
Florian WildeandClaude Opus 5 80d97467da
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
fix: Reviewbefunde aus der Durchsicht von Phase 2 (#036, #027)
Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.

Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.

Schwer:

- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
  (/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
  alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
  jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
  dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
  eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.

Mittel:

- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
  durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
  Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
  jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
  exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
  Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
  Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
  Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
  der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
  angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
  Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
  Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
  Graph-Leser sich im Kommentar darauf berief.

Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.

Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.

1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.

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

277 lines
12 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 { 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<typeof import("@/server/auth/sitzungspflicht")>();
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<string> {
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<string, unknown>;
// 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);
});
});