This commit was merged in pull request #59.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:?");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user