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 = {}) { 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>` 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>` 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"); }); });