487 lines
16 KiB
TypeScript
487 lines
16 KiB
TypeScript
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);
|
||
});
|
||
});
|