Schnellerfassung im Alarmfall, sechs Zeitstempel mit Ausrueckzeit, Anfahrt und Hilfsfrist, Staerke nach FwDV 3, Personalvorschlag mit Qualifikationsstand, Einsatzbericht als PDF, Jahresexport fuer die Gemeinde, Statistik und Uebungen mit Anwesenheitsliste. Dazu das Einsatzportal fuer Fremdwehren: ein signierter Link ohne Anmeldung, zeitlich und mengenbegrenzt, der Stichwort, Ort, Zeiten und Lagemeldungen zeigt und Meldungen mit Foto annimmt. Es sind die ersten oeffentlich erreichbaren Endpunkte in FLORIAN. Acht neue Tabellen, alle mit organisationId und Row Level Security. Ein freigegebener Einsatzbericht ist unveraenderlich; ein Datenbanktrigger setzt das durch. Koordinaten liegen doppelt — als Dezimalgrad und als PostGIS-Punkt, zusammengehalten von einem Trigger. 57 neue Tests (32 Unit, 14 gegen eine echte Datenbank, 11 E2E). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
import { PrismaClient } from "@prisma/client";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { tokenZerlegen, tokenZusammensetzen } from "@/lib/einsatz/portal";
|
|
|
|
/**
|
|
* Was die Datenbank bei Einsätzen durchsetzt.
|
|
*
|
|
* Drei Dinge, die nicht im Anwendungscode stehen dürfen, weil ein vergessener
|
|
* Zweig dort ausreichen würde:
|
|
*
|
|
* 1. Mandantentrennung der acht neuen Tabellen
|
|
* 2. Der Gleichlauf von Dezimalgrad und PostGIS-Punkt
|
|
* 3. Die Unveränderbarkeit freigegebener Einsatzberichte
|
|
*
|
|
* Dazu die Zerlegung des Portal-Tokens, die ohne Datenbank auskommt.
|
|
*
|
|
* Ohne erreichbare Datenbank überspringen sich die ersten drei Gruppen 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-einsatz-wehr";
|
|
|
|
let erreichbar = false;
|
|
let organisationId = "";
|
|
let personId = "";
|
|
|
|
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: "Einsatz-Testwehr", kurzname: "ETW" },
|
|
});
|
|
organisationId = organisation.id;
|
|
|
|
const person = await system.person.upsert({
|
|
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: "E001" } },
|
|
update: {},
|
|
create: { organisationId, mitgliedsnummer: "E001", vorname: "Erika", nachname: "Einsatz" },
|
|
});
|
|
personId = person.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (erreichbar) {
|
|
await system.$transaction(async (tx) => {
|
|
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
|
await tx.organisation.deleteMany({ where: { slug: SLUG } });
|
|
});
|
|
}
|
|
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
|
|
});
|
|
|
|
let laufend = 0;
|
|
|
|
/** Legt einen Einsatz an — mit fortlaufender Nummer, damit Tests sich nicht stören. */
|
|
async function einsatzAnlegen(felder: Record<string, unknown> = {}) {
|
|
laufend += 1;
|
|
return system.einsatz.create({
|
|
data: {
|
|
organisationId,
|
|
einsatznummer: `2026-${String(9000 + laufend)}`,
|
|
stichwort: "Testeinsatz",
|
|
alarmiertUm: new Date("2026-06-15T01:42:00.000Z"),
|
|
...felder,
|
|
},
|
|
});
|
|
}
|
|
|
|
describe.runIf(process.env.DATABASE_URL)("Koordinaten", () => {
|
|
it("setzt den PostGIS-Punkt aus den Dezimalgraden", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const einsatz = await einsatzAnlegen({ breitengrad: 48.137154, laengengrad: 11.576124 });
|
|
|
|
const [zeile] = await system.$queryRaw<Array<{ laenge: number; breite: number }>>`
|
|
SELECT ST_X("koordinaten"::geometry) AS laenge, ST_Y("koordinaten"::geometry) AS breite
|
|
FROM "einsatz" WHERE "id" = ${einsatz.id}::uuid
|
|
`;
|
|
|
|
expect(zeile?.breite).toBeCloseTo(48.137154, 5);
|
|
expect(zeile?.laenge).toBeCloseTo(11.576124, 5);
|
|
});
|
|
|
|
it("räumt den Punkt wieder ab, wenn die Koordinaten entfallen", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const einsatz = await einsatzAnlegen({ breitengrad: 48.1, laengengrad: 11.5 });
|
|
|
|
await system.einsatz.update({
|
|
where: { id: einsatz.id },
|
|
data: { breitengrad: null, laengengrad: null },
|
|
});
|
|
|
|
const [zeile] = await system.$queryRaw<Array<{ vorhanden: boolean }>>`
|
|
SELECT ("koordinaten" IS NOT NULL) AS vorhanden
|
|
FROM "einsatz" WHERE "id" = ${einsatz.id}::uuid
|
|
`;
|
|
|
|
expect(zeile?.vorhanden).toBe(false);
|
|
});
|
|
|
|
it("weist Koordinaten ab, die es auf der Erde nicht gibt", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
await expect(einsatzAnlegen({ breitengrad: 95, laengengrad: 11.5 })).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe.runIf(process.env.DATABASE_URL)("Freigegebene Einsatzberichte", () => {
|
|
it("lässt sich bis zur Freigabe frei ändern", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const einsatz = await einsatzAnlegen({ status: "ABGESCHLOSSEN" });
|
|
|
|
const geaendert = await system.einsatz.update({
|
|
where: { id: einsatz.id },
|
|
data: { bericht: "wird noch geschrieben" },
|
|
});
|
|
|
|
expect(geaendert.bericht).toBe("wird noch geschrieben");
|
|
});
|
|
|
|
it("weist jede Änderung nach der Freigabe ab", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const einsatz = await einsatzAnlegen({
|
|
status: "FREIGEGEBEN",
|
|
freigegebenAm: new Date(),
|
|
bericht: "So war es.",
|
|
});
|
|
|
|
await expect(
|
|
system.einsatz.update({ where: { id: einsatz.id }, data: { bericht: "Doch anders." } }),
|
|
).rejects.toThrow(/unveraenderlich/i);
|
|
|
|
await expect(
|
|
system.einsatz.update({ where: { id: einsatz.id }, data: { stichwort: "Anderes" } }),
|
|
).rejects.toThrow(/unveraenderlich/i);
|
|
});
|
|
|
|
it("lässt das Berichts-PDF nachtragen, solange keines hinterlegt ist", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const einsatz = await einsatzAnlegen({ status: "FREIGEGEBEN", freigegebenAm: new Date() });
|
|
|
|
const datei = await system.datei.create({
|
|
data: {
|
|
organisationId,
|
|
bucket: "test",
|
|
objektSchluessel: `test/${einsatz.id}.pdf`,
|
|
dateiname: "bericht.pdf",
|
|
mimeTyp: "application/pdf",
|
|
groesseBytes: BigInt(2048),
|
|
},
|
|
});
|
|
|
|
const mitPdf = await system.einsatz.update({
|
|
where: { id: einsatz.id },
|
|
data: { berichtDateiId: datei.id },
|
|
});
|
|
|
|
expect(mitPdf.berichtDateiId).toBe(datei.id);
|
|
});
|
|
|
|
it("sperrt den Umweg über die Einsatzkräfte", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const einsatz = await einsatzAnlegen({ status: "ABGESCHLOSSEN" });
|
|
|
|
const kraft = await system.einsatzkraft.create({
|
|
data: { organisationId, einsatzId: einsatz.id, personId, funktion: "TRUPPMANN" },
|
|
});
|
|
|
|
await system.einsatz.update({
|
|
where: { id: einsatz.id },
|
|
data: { status: "FREIGEGEBEN", freigegebenAm: new Date() },
|
|
});
|
|
|
|
await expect(
|
|
system.einsatzkraft.update({
|
|
where: { id: kraft.id },
|
|
data: { funktion: "GRUPPENFUEHRER" },
|
|
}),
|
|
).rejects.toThrow(/freigegeben/i);
|
|
|
|
await expect(
|
|
system.einsatzkraft.create({
|
|
data: { organisationId, einsatzId: einsatz.id, personId, funktion: "MELDER" },
|
|
}),
|
|
).rejects.toThrow(/freigegeben/i);
|
|
});
|
|
|
|
it("lässt Lagemeldungen ausdrücklich weiter zu", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
// Eine Meldung aus dem Portal kann eintreffen, wenn der Bericht längst
|
|
// geschrieben ist. Der Verlauf ist kein Bestandteil des Berichts.
|
|
const einsatz = await einsatzAnlegen({ status: "FREIGEGEBEN", freigegebenAm: new Date() });
|
|
|
|
const meldung = await system.lagemeldung.create({
|
|
data: {
|
|
organisationId,
|
|
einsatzId: einsatz.id,
|
|
text: "Nachtrag von der Nachbarwehr",
|
|
quelle: "PORTAL",
|
|
},
|
|
});
|
|
|
|
expect(meldung.id).toBeTruthy();
|
|
});
|
|
|
|
it("verhindert das Löschen freigegebener Berichte", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const einsatz = await einsatzAnlegen({ status: "FREIGEGEBEN", freigegebenAm: new Date() });
|
|
|
|
await expect(system.einsatz.delete({ where: { id: einsatz.id } })).rejects.toThrow(
|
|
/nicht geloescht/i,
|
|
);
|
|
|
|
await system.$transaction(async (tx) => {
|
|
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
|
await tx.einsatz.delete({ where: { id: einsatz.id } });
|
|
});
|
|
|
|
expect(await system.einsatz.findUnique({ where: { id: einsatz.id } })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der Einsatztabellen", () => {
|
|
it("liefert ohne gesetzten Mandantenkontext keine Zeilen", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
await einsatzAnlegen();
|
|
|
|
const gefunden = await app.$transaction(async (tx) => ({
|
|
einsaetze: await tx.einsatz.findMany(),
|
|
kraefte: await tx.einsatzkraft.findMany(),
|
|
meldungen: await tx.lagemeldung.findMany(),
|
|
zugaenge: await tx.portalZugang.findMany(),
|
|
uebungen: await tx.uebung.findMany(),
|
|
}));
|
|
|
|
expect(gefunden.einsaetze).toHaveLength(0);
|
|
expect(gefunden.kraefte).toHaveLength(0);
|
|
expect(gefunden.meldungen).toHaveLength(0);
|
|
expect(gefunden.zugaenge).toHaveLength(0);
|
|
expect(gefunden.uebungen).toHaveLength(0);
|
|
});
|
|
|
|
it("zeigt im Kontext der Wehr nur ihre eigenen Einsätze", async ({ skip }) => {
|
|
if (!erreichbar) return skip();
|
|
|
|
const einsaetze = await app.$transaction(async (tx) => {
|
|
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
|
return tx.einsatz.findMany();
|
|
});
|
|
|
|
expect(einsaetze.length).toBeGreaterThan(0);
|
|
expect(einsaetze.every((einsatz) => einsatz.organisationId === organisationId)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Portal-Token", () => {
|
|
it("setzt Kennung und Geheimnis zusammen und zerlegt sie wieder", () => {
|
|
const kennung = "3f2504e0-4f89-11d3-9a0c-0305e82c3301";
|
|
const geheimnis = "abcdefghijklmnopqrstuvwxyz012345";
|
|
|
|
const token = tokenZusammensetzen(kennung, geheimnis);
|
|
expect(tokenZerlegen(token)).toEqual({ zugangId: kennung, geheimnis });
|
|
});
|
|
|
|
it("weist alles ab, was nicht wie ein Token aussieht", () => {
|
|
expect(tokenZerlegen("")).toBeNull();
|
|
expect(tokenZerlegen("ohnetrenner")).toBeNull();
|
|
expect(tokenZerlegen(".nurgeheimnis")).toBeNull();
|
|
expect(tokenZerlegen("3f2504e0-4f89-11d3-9a0c-0305e82c3301.")).toBeNull();
|
|
// Keine gültige Kennung
|
|
expect(tokenZerlegen("nichtsGutes.abcdefghijklmnopqrstuvwxyz012345")).toBeNull();
|
|
// Geheimnis zu kurz
|
|
expect(tokenZerlegen("3f2504e0-4f89-11d3-9a0c-0305e82c3301.kurz")).toBeNull();
|
|
});
|
|
|
|
it("verträgt Trennzeichen im Geheimnis nicht als Verwechslung", () => {
|
|
// base64url enthält kein "." — der erste Punkt trennt also eindeutig.
|
|
const token = tokenZusammensetzen(
|
|
"3f2504e0-4f89-11d3-9a0c-0305e82c3301",
|
|
"abc_def-ghi012345678901234567890",
|
|
);
|
|
expect(tokenZerlegen(token)?.geheimnis).toBe("abc_def-ghi012345678901234567890");
|
|
});
|
|
});
|