Phase 5: das sicherheitsrelevanteste Modul des Projekts. Ueber jeder Ansicht steht dauerhaft, dass die analoge Atemschutzueberwachung fuehrend bleibt — abschaltbar nur zusammen mit der Fundstelle einer Freigabe des Kommandanten. Es gibt keinen Timer. In der Datenbank stehen Zeitstempel; wie lange ein Trupp unter Atemschutz ist, rechnet jede Ansicht bei jedem Bild neu aus. Damit ist die Anforderung "uebersteht Neuladen und Verbindungsabbruch" keine Funktion, die gelingen muss, sondern eine Eigenschaft, die sich nicht verlieren laesst. Server und Browser rechnen mit denselben Funktionen. Der Rueckzugsdruck folgt dem doppelten Anmarschverbrauch: was der Weg hinein gekostet hat, kostet der Weg zurueck noch einmal. Bis der Trupp das Einsatzziel meldet, greift die Zwei-Drittel-Regel — sie liegt hoeher und ist damit die vorsichtigere Annahme. Das Protokoll ist doppelt gesichert: entzogene Rechte und ein Trigger auf druckmessung und atemschutz_ereignis. Ein Fehler wird durch einen neuen Eintrag richtiggestellt, nicht durch Aendern. Verteilt wird ueber Server-Sent Events statt Socket.IO — es geht nur in eine Richtung, und so bleibt das Deployment bei "next start". Behoben: ein abgerissener Ereignisstrom meldete sich nicht. Die Anzeige haette weiter "Verbunden" behauptet, waehrend der Draht tot war. Jetzt gilt Stille von acht Sekunden als Abriss. Dazu zwei Altlasten: der Navigationseintrag "Protokoll" fuehrte ins Leere, und die Startseite stand noch auf Phase 1. 68 neue Tests (51 Unit, 17 gegen eine echte Datenbank, 8 E2E je Browser). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import { PrismaClient } from "@prisma/client";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
/**
|
|
* Was die Datenbank beim Atemschutz durchsetzt.
|
|
*
|
|
* Dieses Modul ist das sicherheitsrelevanteste im Projekt, und im Schadensfall
|
|
* ist das Protokoll das Papier, nach dem gefragt wird. Deshalb liegen die
|
|
* Regeln nicht (nur) im Anwendungscode:
|
|
*
|
|
* 1. Druckmessungen und Ereignisse lassen sich nicht ändern und nicht
|
|
* löschen — weder über die Anwendung noch über den Systemzugang.
|
|
* 2. Unmögliche Werte werden abgewiesen: negativer Druck, ein Rückzug vor
|
|
* dem Einstieg, ein Ende vor dem Beginn.
|
|
* 3. Genau ein Truppführer je Trupp.
|
|
* 4. Mandantentrennung der sieben neuen Tabellen.
|
|
*
|
|
* Ohne erreichbare Datenbank überspringen sich die Tests selbst.
|
|
*/
|
|
|
|
const APP_URL = process.env.DATABASE_URL ?? "";
|
|
const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL;
|
|
|
|
const app = new PrismaClient({ datasources: { db: { url: APP_URL } } });
|
|
const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } });
|
|
|
|
const SLUG = "test-atemschutz-wehr";
|
|
|
|
let erreichbar = false;
|
|
let organisationId = "";
|
|
let personA = "";
|
|
let personB = "";
|
|
let ueberwachungId = "";
|
|
|
|
beforeAll(async () => {
|
|
try {
|
|
await system.$queryRaw`SELECT 1`;
|
|
erreichbar = true;
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const organisation = await system.organisation.upsert({
|
|
where: { slug: SLUG },
|
|
update: {},
|
|
create: { slug: SLUG, name: "Atemschutz-Testwehr", kurzname: "ATW" },
|
|
});
|
|
organisationId = organisation.id;
|
|
|
|
const anlegen = async (nummer: string, vorname: string, nachname: string) => {
|
|
const person = await system.person.upsert({
|
|
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: nummer } },
|
|
update: {},
|
|
create: { organisationId, mitgliedsnummer: nummer, vorname, nachname },
|
|
});
|
|
return person.id;
|
|
};
|
|
|
|
personA = await anlegen("A001", "Anton", "Atemschutz");
|
|
personB = await anlegen("A002", "Berta", "Atemschutz");
|
|
|
|
const ueberwachung = await system.atemschutzeinsatz.create({
|
|
data: {
|
|
organisationId,
|
|
bezeichnung: "Testüberwachung",
|
|
ueberwacherId: personA,
|
|
beginnUm: new Date("2026-06-01T10:00:00.000Z"),
|
|
},
|
|
});
|
|
ueberwachungId = ueberwachung.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (erreichbar) {
|
|
await system.$transaction(async (tx) => {
|
|
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
|
// Protokollzeilen lassen sich nicht loeschen — die Trigger sitzen auf
|
|
// den Tabellen. Fuer das Aufraeumen der Testdaten werden sie kurz
|
|
// abgeschaltet; im Betrieb passiert das nie.
|
|
await tx.$executeRawUnsafe('ALTER TABLE "druckmessung" DISABLE TRIGGER USER');
|
|
await tx.$executeRawUnsafe('ALTER TABLE "atemschutz_ereignis" DISABLE TRIGGER USER');
|
|
await tx.organisation.deleteMany({ where: { slug: SLUG } });
|
|
await tx.$executeRawUnsafe('ALTER TABLE "druckmessung" ENABLE TRIGGER USER');
|
|
await tx.$executeRawUnsafe('ALTER TABLE "atemschutz_ereignis" ENABLE TRIGGER USER');
|
|
});
|
|
}
|
|
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
|
|
});
|
|
|
|
/** Legt einen Trupp mit zwei Personen an. */
|
|
async function truppAnlegen(funkrufname: string, felder: Record<string, unknown> = {}) {
|
|
return system.atemschutztrupp.create({
|
|
data: {
|
|
organisationId,
|
|
atemschutzeinsatzId: ueberwachungId,
|
|
funkrufname,
|
|
angelegtUm: new Date("2026-06-01T10:05:00.000Z"),
|
|
mitglieder: {
|
|
create: [
|
|
{ organisationId, personId: personA, truppfuehrer: true },
|
|
{ organisationId, personId: personB, truppfuehrer: false },
|
|
],
|
|
},
|
|
...felder,
|
|
},
|
|
});
|
|
}
|
|
|
|
describe.runIf(process.env.DATABASE_URL)("Das Protokoll ist unveränderlich", () => {
|
|
it("nimmt eine Druckmessung an", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const trupp = await truppAnlegen("Messtrupp");
|
|
|
|
const messung = await system.druckmessung.create({
|
|
data: {
|
|
organisationId,
|
|
truppId: trupp.id,
|
|
personId: personA,
|
|
druck: 300,
|
|
art: "EINGANG",
|
|
},
|
|
});
|
|
|
|
expect(messung.druck).toBe(300);
|
|
});
|
|
|
|
it("weist jede Änderung einer Druckmessung ab", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const trupp = await truppAnlegen("Änderungstrupp");
|
|
const messung = await system.druckmessung.create({
|
|
data: { organisationId, truppId: trupp.id, personId: personA, druck: 280, art: "ZWISCHEN" },
|
|
});
|
|
|
|
// Ein Tippfehler wird durch eine neue Messung richtiggestellt — nicht
|
|
// durch Überschreiben. Sonst ließe sich jede Aufzeichnung glätten.
|
|
await expect(
|
|
system.druckmessung.update({ where: { id: messung.id }, data: { druck: 180 } }),
|
|
).rejects.toThrow(/unveraenderlich/i);
|
|
});
|
|
|
|
it("weist das Löschen einer Druckmessung ab", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const trupp = await truppAnlegen("Löschtrupp");
|
|
const messung = await system.druckmessung.create({
|
|
data: { organisationId, truppId: trupp.id, personId: personA, druck: 260, art: "ZWISCHEN" },
|
|
});
|
|
|
|
await expect(
|
|
system.druckmessung.delete({ where: { id: messung.id } }),
|
|
).rejects.toThrow(/unveraenderlich/i);
|
|
});
|
|
|
|
it("weist jede Änderung eines Ereignisses ab", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const ereignis = await system.atemschutzEreignis.create({
|
|
data: {
|
|
organisationId,
|
|
atemschutzeinsatzId: ueberwachungId,
|
|
art: "MAYDAY",
|
|
text: "MAYDAY — Angriffstrupp",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
system.atemschutzEreignis.update({
|
|
where: { id: ereignis.id },
|
|
data: { text: "war doch nichts" },
|
|
}),
|
|
).rejects.toThrow(/unveraenderlich/i);
|
|
});
|
|
});
|
|
|
|
describe.runIf(process.env.DATABASE_URL)("Werte, die es nicht geben darf", () => {
|
|
it("weist einen negativen Druck ab", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const trupp = await truppAnlegen("Minustrupp");
|
|
|
|
await expect(
|
|
system.druckmessung.create({
|
|
data: { organisationId, truppId: trupp.id, personId: personA, druck: -5, art: "ZWISCHEN" },
|
|
}),
|
|
).rejects.toThrow(/druckmessung_druck_moeglich/i);
|
|
});
|
|
|
|
it("weist einen Druck jenseits jeder Flasche ab", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const trupp = await truppAnlegen("Zahlendrehertrupp");
|
|
|
|
// 3000 statt 300 — der klassische Tippfehler unter Zeitdruck.
|
|
await expect(
|
|
system.druckmessung.create({
|
|
data: { organisationId, truppId: trupp.id, personId: personA, druck: 3000, art: "EINGANG" },
|
|
}),
|
|
).rejects.toThrow(/druckmessung_druck_moeglich/i);
|
|
});
|
|
|
|
it("weist einen Rückzug vor dem Einstieg ab", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
await expect(
|
|
truppAnlegen("Verdrehttrupp", {
|
|
einstiegUm: new Date("2026-06-01T10:30:00.000Z"),
|
|
rueckzugUm: new Date("2026-06-01T10:20:00.000Z"),
|
|
}),
|
|
).rejects.toThrow(/trupp_rueckzug_nach_einstieg/i);
|
|
});
|
|
|
|
it("weist einen Rückzug ohne Einstieg ab", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
// Ein Trupp, der zurückgeht, ohne je drin gewesen zu sein, ist kein
|
|
// Trupp, sondern ein Erfassungsfehler.
|
|
await expect(
|
|
truppAnlegen("Geistertrupp", { rueckzugUm: new Date("2026-06-01T10:20:00.000Z") }),
|
|
).rejects.toThrow(/trupp_rueckzug_nach_einstieg/i);
|
|
});
|
|
|
|
it("weist eine Aufhebung ohne Notfall ab", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
await expect(
|
|
truppAnlegen("Scheintrupp", {
|
|
maydayAufgehobenUm: new Date("2026-06-01T10:40:00.000Z"),
|
|
}),
|
|
).rejects.toThrow(/trupp_mayday_aufhebung_danach/i);
|
|
});
|
|
|
|
it("weist ein Ende vor dem Beginn ab", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
await expect(
|
|
system.atemschutzeinsatz.create({
|
|
data: {
|
|
organisationId,
|
|
bezeichnung: "Rückwärtsüberwachung",
|
|
ueberwacherId: personA,
|
|
beginnUm: new Date("2026-06-01T12:00:00.000Z"),
|
|
endeUm: new Date("2026-06-01T11:00:00.000Z"),
|
|
},
|
|
}),
|
|
).rejects.toThrow(/atemschutz_ende_nach_beginn/i);
|
|
});
|
|
|
|
it("weist ein unsinniges Abfrageintervall ab", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
await expect(
|
|
system.atemschutzeinsatz.create({
|
|
data: {
|
|
organisationId,
|
|
bezeichnung: "Sekundentakt",
|
|
ueberwacherId: personA,
|
|
beginnUm: new Date("2026-06-01T12:00:00.000Z"),
|
|
abfrageIntervall: 5,
|
|
},
|
|
}),
|
|
).rejects.toThrow(/atemschutz_intervall_sinnvoll/i);
|
|
});
|
|
|
|
it("weist eine G26 ab, die vor der Untersuchung endet", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
await expect(
|
|
system.g26Untersuchung.create({
|
|
data: {
|
|
organisationId,
|
|
personId: personA,
|
|
untersuchtAm: new Date("2026-06-01T00:00:00.000Z"),
|
|
gueltigBis: new Date("2025-06-01T00:00:00.000Z"),
|
|
},
|
|
}),
|
|
).rejects.toThrow(/g26_gueltig_nach_untersuchung/i);
|
|
});
|
|
});
|
|
|
|
describe.runIf(process.env.DATABASE_URL)("Genau ein Truppführer", () => {
|
|
it("lässt einen zweiten Truppführer nicht zu", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const trupp = await truppAnlegen("Doppelfuehrertrupp");
|
|
|
|
// Im Protokoll muss stehen, wer verantwortlich war — genau einer. Der
|
|
// Teilindex truppmitglied_ein_fuehrer setzt das durch; Prisma meldet ihn
|
|
// als verletzte Eindeutigkeit auf truppId.
|
|
await expect(
|
|
system.truppmitglied.update({
|
|
where: { truppId_personId: { truppId: trupp.id, personId: personB } },
|
|
data: { truppfuehrer: true },
|
|
}),
|
|
).rejects.toThrow(/unique constraint failed.*truppId/i);
|
|
});
|
|
|
|
it("lässt dieselbe Person nicht zweimal im Trupp zu", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const trupp = await truppAnlegen("Doppeltrupp");
|
|
|
|
await expect(
|
|
system.truppmitglied.create({
|
|
data: { organisationId, truppId: trupp.id, personId: personA },
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung des Atemschutzes", () => {
|
|
it("zeigt ohne gesetzte Wehr nichts", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const gefunden = await app.$transaction(async (tx) => ({
|
|
ueberwachungen: await tx.atemschutzeinsatz.findMany(),
|
|
trupps: await tx.atemschutztrupp.findMany(),
|
|
mitglieder: await tx.truppmitglied.findMany(),
|
|
messungen: await tx.druckmessung.findMany(),
|
|
ereignisse: await tx.atemschutzEreignis.findMany(),
|
|
untersuchungen: await tx.g26Untersuchung.findMany(),
|
|
uebungen: await tx.belastungsuebung.findMany(),
|
|
}));
|
|
|
|
for (const [tabelle, zeilen] of Object.entries(gefunden)) {
|
|
expect(zeilen, `${tabelle} darf ohne Mandantenkontext nichts zeigen`).toHaveLength(0);
|
|
}
|
|
});
|
|
|
|
it("zeigt im Kontext der Wehr nur deren eigene Überwachungen", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const ueberwachungen = await app.$transaction(async (tx) => {
|
|
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
|
return tx.atemschutzeinsatz.findMany();
|
|
});
|
|
|
|
expect(ueberwachungen.length).toBeGreaterThan(0);
|
|
expect(ueberwachungen.every((eintrag) => eintrag.organisationId === organisationId)).toBe(true);
|
|
});
|
|
|
|
it("lässt den Anwendungszugang eine Messung anlegen, aber nicht ändern", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const trupp = await truppAnlegen("Rechtetrupp");
|
|
|
|
const messungId = await app.$transaction(async (tx) => {
|
|
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
|
const messung = await tx.druckmessung.create({
|
|
data: { organisationId, truppId: trupp.id, personId: personA, druck: 210, art: "ZWISCHEN" },
|
|
});
|
|
return messung.id;
|
|
});
|
|
|
|
expect(messungId).toBeTruthy();
|
|
|
|
await expect(
|
|
app.$transaction(async (tx) => {
|
|
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
|
return tx.$executeRaw`UPDATE "druckmessung" SET "druck" = 999 WHERE "id" = ${messungId}::uuid`;
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|