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>
96 lines
3.9 KiB
TypeScript
96 lines
3.9 KiB
TypeScript
import { readFileSync } from "node:fs";
|
||
import { describe, expect, it } from "vitest";
|
||
import { parse } from "yaml";
|
||
|
||
/**
|
||
* Die Datenbank im Betriebsstand ist absichtlich von aussen nicht erreichbar.
|
||
* Diese Tests halten das fest, damit ein ports:-Mapping nicht versehentlich
|
||
* zurueckkommt. Siehe docs/betrieb/inbetriebnahme.md.
|
||
*/
|
||
function compose(datei: string): Record<string, never> {
|
||
return parse(readFileSync(datei, "utf8")) as Record<string, never>;
|
||
}
|
||
|
||
describe("Betriebsstand: docker-compose.yml", () => {
|
||
const stand = compose("docker-compose.yml") as unknown as {
|
||
services: Record<string, { ports?: unknown[]; networks?: string[] }>;
|
||
networks: Record<string, { internal?: boolean } | null>;
|
||
};
|
||
|
||
it("veroeffentlicht keinen Port der Datenbank", () => {
|
||
expect(stand.services["postgres"]?.ports).toBeUndefined();
|
||
});
|
||
|
||
it("haengt die Datenbank nur in das interne Netz", () => {
|
||
expect(stand.services["postgres"]?.networks).toEqual(["intern"]);
|
||
});
|
||
|
||
it("hat das interne Netz ohne Gateway", () => {
|
||
expect(stand.networks["intern"]?.internal).toBe(true);
|
||
});
|
||
|
||
it("laesst nur den Webdienst in das externe Netz", () => {
|
||
const aussen = Object.entries(stand.services)
|
||
.filter(([, dienst]) => dienst.networks?.includes("extern"))
|
||
.map(([name]) => name);
|
||
expect(aussen).toEqual(["web"]);
|
||
});
|
||
|
||
it("bindet den Webdienst standardmaessig nur auf localhost", () => {
|
||
expect(stand.services["web"]?.ports).toEqual(["${BIND_ADDR:-127.0.0.1}:${APP_PORT:-3000}:3000"]);
|
||
});
|
||
|
||
it("prueft die Gesundheit des Webdienstes ueber IPv4, nicht ueber localhost", () => {
|
||
// busybox-wget loest localhost zuerst nach ::1 auf, Next lauscht nur auf
|
||
// 0.0.0.0:3000. Mit localhost stand web dauerhaft auf (unhealthy), obwohl
|
||
// die App antwortete (#019).
|
||
const probe = (stand.services["web"] as unknown as { healthcheck?: { test?: string[] } }).healthcheck
|
||
?.test;
|
||
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:?");
|
||
expect(roh).toContain("${POSTGRES_PASSWORD:?");
|
||
expect(roh).toContain("${APP_URL:?");
|
||
});
|
||
});
|
||
|
||
describe("Betriebsstand: Entwicklungsdatenbank", () => {
|
||
it("bindet nur auf localhost und auf einen eigenen, umstellbaren Port", () => {
|
||
const stand = compose("docker-compose.dev.yml") as unknown as {
|
||
services: Record<string, { ports?: string[] }>;
|
||
};
|
||
// Der Standard ist 5434, weil 5432 lokal und 5433 von einem anderen Projekt
|
||
// belegt sein koennen. Umstellbar ueber DEV_DB_PORT.
|
||
expect(stand.services["postgres"]?.ports).toEqual(["127.0.0.1:${DEV_DB_PORT:-5434}:5432"]);
|
||
});
|
||
});
|