import { createServer, type Server } from "node:http"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { Betriebsmodus, LaufStatus, Meldeanlass, VersionStatus, Zustellweg, Zustellzustand, } from "@/generated/prisma/enums"; import { abgleichMeldung, laufMeldung, webhookRumpf } from "@/lib/meldungen"; import { SIGNATUR_KOPF, signaturBauen, signaturPruefen } from "@/lib/signatur"; import { verschluesseln } from "@/lib/geheimnisse"; import { MELDUNGS_PFADE, WARTEZEITEN_MINUTEN, abgleichMeldungEinreihen, laufMeldungEinreihen, meldungseinstellungenHolen, meldungseinstellungenSpeichern, naechsterVersuch, ueberfaelligeEinreihen, warteschlangeAbarbeiten, } from "@/server/services/benachrichtigungen"; import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; /** * Benachrichtigungen per Mail und Webhook (#034). * * Fuenf Zusagen tragen das Ganze, und jede hat hier ihren Test: * * 1. Die vier Anlaesse gehen an die richtigen Empfaenger — **gegen einen echten * SMTP-Faenger**, nicht gegen eine abgefangene Funktion. * 2. In **keiner** Nachricht steht ein Schrittinhalt und damit kein erzeugtes * Passwort. * 3. Eine fehlgeschlagene Zustellung wird wiederholt und ist danach sichtbar. * 4. Zwei gleichzeitige Durchgaenge schicken nichts zweimal. * 5. Wer Benachrichtigungen abgeschaltet hat, bekommt nichts. */ const MAILPIT = "http://127.0.0.1:8025"; const SMTP_PORT = 1025; /** * Steht der Faenger? * * **Auf Modulebene geprueft, nicht in `beforeAll`.** `it.runIf(...)` wird beim * Einlesen der Datei ausgewertet — ein in `beforeAll` gesetztes Flag kaeme zu * spaet, und alle Mailtests waeren stillschweigend uebersprungen. Genau so ist * es beim ersten Lauf passiert: 3 skipped, gruener Durchlauf, nichts geprueft. */ async function mailpitAntwortet(): Promise { try { const antwort = await fetch(`${MAILPIT}/api/v1/info`, { signal: AbortSignal.timeout(2_000) }); return antwort.ok; } catch { return false; } } const mailpitDa = await mailpitAntwortet(); if (!mailpitDa && process.env["CI"]) { throw new Error( "Mailpit antwortet nicht auf 8025. In CI duerfen die Mailtests nicht uebersprungen werden — " + "`docker compose -f docker-compose.test.yml up -d --wait mailpit`.", ); } if (!mailpitDa) { console.warn( "Mailpit laeuft nicht; die Mailtests werden uebersprungen. " + "Start: docker compose -f docker-compose.test.yml up -d --wait mailpit", ); } type MailpitNachricht = { readonly ID: string; readonly Subject: string; readonly To: readonly { readonly Address: string }[]; }; async function mailpitLeeren(): Promise { await fetch(`${MAILPIT}/api/v1/messages`, { method: "DELETE" }); } async function mailpitNachrichten(): Promise { const antwort = await fetch(`${MAILPIT}/api/v1/messages?limit=100`); const daten = (await antwort.json()) as { messages?: MailpitNachricht[] }; return daten.messages ?? []; } /** Der Klartext einer Nachricht – fuer die Passwort-Pruefung. */ async function mailpitText(id: string): Promise { const antwort = await fetch(`${MAILPIT}/api/v1/message/${id}`); const daten = (await antwort.json()) as { Text?: string; Subject?: string }; return `${daten.Subject ?? ""}\n${daten.Text ?? ""}`; } /* ------------------------------------------------------------------ */ /* Die reinen Teile */ /* ------------------------------------------------------------------ */ describe("Die Signatur eines Webhooks", () => { const GEHEIMNIS = "sehr-geheim"; const RUMPF = '{"anlass":"LAUF_ZUGEWIESEN"}'; const JETZT = 1_762_334_400; it("lässt sich mit dem Geheimnis nachrechnen", () => { const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT); expect(kopf).toMatch(/^t=1762334400,v1=[0-9a-f]{64}$/); expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT)).toEqual({ gueltig: true }); }); it("mit einem falschen Geheimnis nicht", () => { const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT); expect(signaturPruefen("falsch", RUMPF, kopf, JETZT)).toEqual({ gueltig: false, grund: "falsch", }); }); it("und auch nicht, wenn der Rumpf nachträglich geändert wurde", () => { const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT); expect(signaturPruefen(GEHEIMNIS, '{"anlass":"ETWAS_ANDERES"}', kopf, JETZT).gueltig).toBe(false); }); it("weist eine zu alte und eine zu junge Nachricht ab", () => { const kopf = signaturBauen(GEHEIMNIS, RUMPF, JETZT); // Zu alt: eine abgefangene Nachricht soll sich nicht morgen einspielen lassen. expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT + 301)).toEqual({ gueltig: false, grund: "abgelaufen", }); /* * Und zu jung. Nur „zu alt“ abzufangen liesse eine Nachricht mit Zeitstempel * im Jahr 2040 fuer immer gueltig sein. */ expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT - 301)).toEqual({ gueltig: false, grund: "abgelaufen", }); // Innerhalb der Toleranz geht beides. expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT + 299).gueltig).toBe(true); }); it("weist einen kaputten Kopf ab, statt zu werfen", () => { for (const kopf of ["", "unsinn", "t=abc,v1=ff", "v1=ff", "t=1762334400"]) { expect(signaturPruefen(GEHEIMNIS, RUMPF, kopf, JETZT)).toEqual({ gueltig: false, grund: "form", }); } }); it("signiert den Zeitstempel mit", () => { /* * Stuende er nur daneben, koennte ihn jeder aendern, ohne die Signatur zu * brechen. Dieselbe Signatur mit einem anderen `t` muss also scheitern. */ const echt = signaturBauen(GEHEIMNIS, RUMPF, JETZT); const geschoben = echt.replace(`t=${JETZT}`, `t=${JETZT + 10}`); expect(signaturPruefen(GEHEIMNIS, RUMPF, geschoben, JETZT + 10)).toEqual({ gueltig: false, grund: "falsch", }); }); }); describe("Die Texte der Meldungen", () => { const KOPF = { titel: "PC Neuinstallation – PC-001", geraet: "PC-001", person: "Maria Huber", faelligAm: new Date("2026-10-07T10:00:00Z"), vorlage: "PC Neuinstallation", handelnder: "Petra Klein", }; it("nennt den Anlass im Betreff", () => { expect(laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, KOPF, "u").betreff).toContain("ist dir zugewiesen"); expect(laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").betreff).toContain("überfällig"); expect(laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, "u").betreff).toContain("abgeschlossen"); expect(abgleichMeldung("AD Zentrale", "Verbindung abgelehnt", "u").betreff).toContain("fehlgeschlagen"); }); it("schreibt das Fälligkeitsdatum deutsch", () => { expect(laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").rumpf).toContain("07.10.2026"); }); it("lässt leere Felder weg, statt „null“ zu schreiben", () => { const dürftig = laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, { titel: "Nur ein Titel" }, "u"); expect(dürftig.rumpf).not.toContain("null"); expect(dürftig.rumpf).not.toContain("Gerät:"); expect(dürftig.rumpf).not.toContain("undefined"); }); it("trägt den Link, damit die Mail zu etwas führt", () => { const link = "https://laufzettel.firma.de/laeufe/abc"; expect(laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, link).rumpf).toContain(link); expect(abgleichMeldung("AD", "Grund", link).rumpf).toContain(link); }); it("zeigt mit jedem Pfad auf eine Seite, die es wirklich gibt", () => { /* * **Der Test, der gefehlt hat.** Oben wird geprüft, dass der *übergebene* * Link im Rumpf steht — das ist richtig und sagt nichts darüber, ob der * Pfad existiert. `/verwaltung/verzeichnis` stand im Dienst und war ein * 404: `verwaltung` ist der Name des Rechts, die Seite liegt unter * `/admin/verzeichnis` (Reviewbefund zu #029). * * Ein Pfad in einer Mail wird nicht nachgesehen, sondern geklickt — und * genau diese eine Meldung geht an **alle** Administratoren, im Moment * eines Ausfalls. Geprüft wird deshalb gegen das Dateisystem: zu jedem Pfad * muss unter `src/app/(app)/` eine `page.tsx` liegen. */ const wurzel = new URL("../src/app/(app)", import.meta.url).pathname; for (const pfad of Object.values(MELDUNGS_PFADE)) { const teile = pfad.replace(/^\//u, "").split("/"); // Ein Pfad, der auf eine Kennung endet, liegt unter `[id]`. const kandidaten = [join(wurzel, ...teile, "page.tsx"), join(wurzel, ...teile, "[id]", "page.tsx")]; expect( kandidaten.some((datei) => existsSync(datei)), `${pfad} zeigt auf keine Seite – gesucht: ${kandidaten.join(" oder ")}`, ).toBe(true); } }); it("nimmt keinen Schrittinhalt auf — der Typ lässt es nicht zu", () => { /* * Die wichtigste Zusage dieses Meilensteins. Geprueft wird sie hier ueber * die **Form**: `laufMeldung` nimmt nur Kopfdaten. Dass auch nichts aus der * Datenbank durchsickert, prueft der Test „in keiner Nachricht steht ein * erzeugtes Passwort“ weiter unten — ueber alle Rumpftexte, wie in #012. */ const alle = [ laufMeldung(Meldeanlass.LAUF_ZUGEWIESEN, KOPF, "u").rumpf, laufMeldung(Meldeanlass.LAUF_UEBERFAELLIG, KOPF, "u").rumpf, laufMeldung(Meldeanlass.LAUF_ABGESCHLOSSEN, KOPF, "u").rumpf, abgleichMeldung("AD", "Grund", "u").rumpf, ].join("\n"); expect(alle).not.toContain("{{"); expect(alle).not.toContain("Password"); expect(alle).not.toContain("New-ADUser"); }); it("baut einen Webhook-Rumpf mit dem Anlass als Feld", () => { const rumpf = webhookRumpf( Meldeanlass.LAUF_ABGESCHLOSSEN, "Betreff", "https://x/laeufe/1", { laufId: "l1" }, new Date("2026-10-05T08:00:00Z"), ); expect(JSON.parse(rumpf)).toEqual({ anlass: "LAUF_ABGESCHLOSSEN", betreff: "Betreff", link: "https://x/laeufe/1", laufId: "l1", verzeichnisquelleId: null, zeitpunkt: "2026-10-05T08:00:00.000Z", }); }); }); describe("Die Wiederholungsregel", () => { const JETZT = new Date("2026-10-05T08:00:00Z"); it("wartet nach dem ersten Fehlschlag 1 Minute, dann 5, 30 und 120", () => { expect(WARTEZEITEN_MINUTEN).toEqual([1, 5, 30, 120]); for (const [index, minuten] of WARTEZEITEN_MINUTEN.entries()) { // `versucheGesamt` zaehlt den gerade gescheiterten mit: 1 → eine Minute. const ergebnis = naechsterVersuch(index + 1, JETZT); expect(ergebnis.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN); expect(ergebnis.naechsterVersuchAm.getTime() - JETZT.getTime()).toBe(minuten * 60_000); } }); it("gibt nach dem fünften Fehlschlag auf — aber sichtbar", () => { const ergebnis = naechsterVersuch(WARTEZEITEN_MINUTEN.length + 1, JETZT); expect(ergebnis.zustand).toBe(Zustellzustand.AUFGEGEBEN); // Eine stille Warteschlange ist schlimmer als keine: der Zeitpunkt bleibt // stehen, damit die Liste ihre Reihenfolge behaelt. expect(ergebnis.naechsterVersuchAm).toEqual(JETZT); }); }); /* ------------------------------------------------------------------ */ /* Mit Datenbank */ /* ------------------------------------------------------------------ */ const APP_URL_VORHER = process.env["APP_URL"]; 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: "Petra Klein", rolle: "ADMIN" }, }); const techniker = await testPrisma.benutzer.create({ data: { email: "technik@firma.example", name: "Ali Demir", rolle: "TECHNIKER" }, }); 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: "Konto", position: 0 }, }); /* * Ein Befehlsschritt mit dem Platzhalter – damit der Passwort-Test etwas zu * finden haette, wenn irgendwo ein Schrittinhalt in eine Mail geriete. */ const schritt = await testPrisma.vorlagenSchritt.create({ data: { abschnittId: abschnitt.id, titel: "Konto anlegen", typ: "BEFEHL", pflicht: true, position: 0, konfig: { shell: "powershell", befehl: 'New-ADUser -AccountPassword "{{generiertes_passwort}}" -Name "Maria Huber"', }, }, }); await testPrisma.vorlagenVersion.update({ where: { id: version.id }, data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() }, }); return { kunde, admin, techniker, versionId: version.id, schrittId: schritt.id }; } async function laufAnlegenRoh( versionId: string, kundeId: string, daten: { titel?: string; zugewiesenAnId?: string | null; erstelltVonId?: string | null; faelligAm?: Date | null; status?: LaufStatus; } = {}, ) { return testPrisma.lauf.create({ data: { vorlagenVersionId: versionId, kundeId, titel: daten.titel ?? "PC Neuinstallation – PC-001", zugewiesenAnId: daten.zugewiesenAnId ?? null, erstelltVonId: daten.erstelltVonId ?? null, faelligAm: daten.faelligAm ?? null, status: daten.status ?? LaufStatus.OFFEN, /* * Die Zeitstempel gehoeren zum Status — `Lauf_begonnen_mit_zeitpunkt` und * `Lauf_abgeschlossen_mit_zeitpunkt` verlangen das in der Datenbank. Ein * Status ohne seinen Zeitpunkt ist hier gar nicht anlegbar, und das ist * richtig so. */ ...(daten.status === LaufStatus.IN_ARBEIT ? { gestartetAm: new Date() } : {}), ...(daten.status === LaufStatus.ABGESCHLOSSEN ? { gestartetAm: new Date(), abgeschlossenAm: new Date() } : {}), }, }); } /** SMTP auf den Faenger zeigen lassen. */ async function smtpEinrichten(): Promise { await testPrisma.einstellung.update({ where: { id: "global" }, data: { smtpHost: "127.0.0.1", smtpPort: SMTP_PORT, smtpTls: false, smtpAbsender: "laufzettel@firma.example", }, }); } describe.runIf(datenbankVerfuegbar)("Einstellungen der Benachrichtigungen", () => { beforeEach(async () => { await datenbankLeeren(); }); it("gibt das SMTP-Passwort nie heraus, nur ob eines gesetzt ist", async () => { const { admin } = await aufbau(); await meldungseinstellungenSpeichern( { smtpHost: "mail.firma.de", smtpPort: 587, smtpBenutzer: "laufzettel", smtpPasswort: "streng-geheim", smtpAbsender: "laufzettel@firma.de", }, admin.id, ); const gelesen = await meldungseinstellungenHolen(); expect(gelesen.smtp.host).toBe("mail.firma.de"); expect(gelesen.smtp.passwortGesetzt).toBe(true); // Der Wert selbst taucht in der Antwort nirgends auf. expect(JSON.stringify(gelesen)).not.toContain("streng-geheim"); // Und in der Datenbank steht er verschlüsselt, nicht im Klartext. const zeile = await testPrisma.einstellung.findUniqueOrThrow({ where: { id: "global" } }); expect(zeile.smtpPasswortVerschluesselt).toMatch(/^v[0-9]+\./); expect(zeile.smtpPasswortVerschluesselt).not.toContain("streng-geheim"); }); it("schreibt kein Geheimnis ins Protokoll", async () => { const { admin } = await aufbau(); await meldungseinstellungenSpeichern( { smtpHost: "mail.firma.de", smtpPort: 587, smtpPasswort: "streng-geheim" }, admin.id, ); await meldungseinstellungenSpeichern( { webhookUrl: "https://hook.firma.de/laufzettel", webhookGeheimnis: "hook-geheim" }, admin.id, ); const protokoll = await testPrisma.protokoll.findMany(); const alsText = JSON.stringify(protokoll); // Das Protokoll ist ansehbar (#023) – hier wäre die Verschlüsselung sinnlos. expect(alsText).not.toContain("streng-geheim"); expect(alsText).not.toContain("hook-geheim"); expect(alsText).toContain("Benachrichtigungen geändert"); }); it("weist eine unbrauchbare Webhook-Adresse und einen falschen Port ab", async () => { const { admin } = await aufbau(); await expect( meldungseinstellungenSpeichern({ webhookUrl: "nicht-mal-eine-url" }, admin.id), ).rejects.toThrow(/gültige URL/); await expect( meldungseinstellungenSpeichern({ webhookUrl: "ftp://hook.firma.de" }, admin.id), ).rejects.toThrow(/http/); await expect( meldungseinstellungenSpeichern({ smtpHost: "mail.firma.de", smtpPort: 70_000 }, admin.id), ).rejects.toThrow(/zwischen 1 und 65535/); }); }); describe.runIf(datenbankVerfuegbar)("Einreihen", () => { beforeAll(() => { process.env["APP_URL"] = "https://laufzettel.firma.example"; }); afterAll(() => { if (APP_URL_VORHER === undefined) delete process.env["APP_URL"]; else process.env["APP_URL"] = APP_URL_VORHER; }); beforeEach(async () => { await datenbankLeeren(); }); it("meldet eine Zuweisung an den Zugewiesenen, nicht an den Zuweisenden", async () => { const { kunde, admin, techniker, versionId } = await aufbau(); const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id, erstelltVonId: admin.id, }); await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id); const zustellungen = await testPrisma.zustellung.findMany(); expect(zustellungen).toHaveLength(1); expect(zustellungen[0]).toMatchObject({ anlass: Meldeanlass.LAUF_ZUGEWIESEN, weg: Zustellweg.MAIL, empfaenger: "technik@firma.example", zustand: Zustellzustand.OFFEN, versuche: 0, }); expect(zustellungen[0]?.rumpf).toContain("https://laufzettel.firma.example/laeufe/"); expect(zustellungen[0]?.rumpf).toContain("Petra Klein"); }); it("schickt niemandem eine Mail über die eigene Tat", async () => { /* * Der haeufigste Grund, Benachrichtigungen ganz abzuschalten: Post ueber * das, was man gerade selbst geklickt hat. */ const { kunde, techniker, versionId } = await aufbau(); const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id }); const erzeugt = await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, techniker.id); expect(erzeugt).toBe(0); expect(await testPrisma.zustellung.count()).toBe(0); }); it("meldet einen Abschluss auch dem, der den Lauf angelegt hat", async () => { const { kunde, admin, techniker, versionId } = await aufbau(); const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id, erstelltVonId: admin.id, }); // Abgeschlossen hat der Techniker – also erfaehrt es der Ersteller. await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id); const empfaenger = (await testPrisma.zustellung.findMany()).map((z) => z.empfaenger); expect(empfaenger).toEqual(["admin@firma.example"]); }); it("schickt nichts an jemanden, der Benachrichtigungen abgeschaltet hat", async () => { const { kunde, admin, techniker, versionId } = await aufbau(); await testPrisma.benutzer.update({ where: { id: techniker.id }, data: { meldungenAktiv: false }, }); const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id }); expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0); }); it("schickt nichts an ein deaktiviertes Konto", async () => { const { kunde, admin, techniker, versionId } = await aufbau(); await testPrisma.benutzer.update({ where: { id: techniker.id }, data: { aktiv: false } }); const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id }); expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0); }); it("achtet die Mandantengrenze", async () => { /* * Eine Meldung traegt Lauftitel und Geraet. Ohne diese Pruefung waere die * Benachrichtigung der eine Weg nach draussen, den `sichtbareKundenIds` * nicht abdeckt. */ const { kunde, admin, techniker, versionId } = await aufbau(); await testPrisma.einstellung.update({ where: { id: "global" }, data: { modus: Betriebsmodus.DIENSTLEISTUNG }, }); const vorher = process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-"; try { const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" }, }); // Der Techniker darf nur den eigenen Kunden sehen … await testPrisma.benutzerKunde.create({ data: { benutzerId: techniker.id, kundeId: kunde.id }, }); // … ist aber einem Lauf des fremden zugewiesen. const lauf = await laufAnlegenRoh(versionId, fremd.id, { zugewiesenAnId: techniker.id }); expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0); } finally { if (vorher === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"]; else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = vorher; } }); it("meldet einen fehlgeschlagenen Abgleich allen Administratoren", async () => { const { kunde, admin } = await aufbau(); const zweiterAdmin = await testPrisma.benutzer.create({ data: { email: "admin2@firma.example", name: "Zweiter Admin", rolle: "ADMIN" }, }); const quelle = await testPrisma.verzeichnisquelle.create({ data: { kundeId: kunde.id, name: "AD Zentrale", url: "ldaps://dc1.firma.test:636", bindDn: "cn=admin,dc=firma,dc=test", bindPasswortVerschluesselt: verschluesseln("egal"), basisDn: "dc=firma,dc=test", filter: "(objectClass=user)", attributZuordnung: {}, }, }); await abgleichMeldungEinreihen(quelle.id, "Verbindung abgelehnt"); const zustellungen = await testPrisma.zustellung.findMany({ orderBy: { empfaenger: "asc" } }); expect(zustellungen.map((z) => z.empfaenger)).toEqual(["admin2@firma.example", "admin@firma.example"]); expect(zustellungen[0]?.rumpf).toContain("Verbindung abgelehnt"); expect(zustellungen[0]?.verzeichnisquelleId).toBe(quelle.id); expect(admin.id).toBeTruthy(); expect(zweiterAdmin.id).toBeTruthy(); }); it("meldet einen überfälligen Lauf genau einmal, auch bei jedem Durchgang", async () => { /* * Der Zeitplan sieht **jede Minute** nach. Ohne den partiellen eindeutigen * Index bekaeme der Zustaendige fuer einen Lauf, der ueber das Wochenende * steht, 2880 Mails — und jede einzelne davon waere fuer sich korrekt. */ const { kunde, techniker, versionId } = await aufbau(); await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id, faelligAm: new Date("2026-10-01T00:00:00Z"), status: LaufStatus.IN_ARBEIT, }); expect(await ueberfaelligeEinreihen()).toBe(1); expect(await ueberfaelligeEinreihen()).toBe(0); expect(await ueberfaelligeEinreihen()).toBe(0); expect(await testPrisma.zustellung.count()).toBe(1); }); it("hält die Einmaligkeit auch gegen einen Schreiber an der Service-Schicht vorbei", async () => { const { kunde, techniker, versionId } = await aufbau(); const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id, faelligAm: new Date("2026-10-01T00:00:00Z"), status: LaufStatus.IN_ARBEIT, }); await ueberfaelligeEinreihen(); await expect( testPrisma.zustellung.create({ data: { anlass: Meldeanlass.LAUF_UEBERFAELLIG, weg: Zustellweg.MAIL, empfaenger: "technik@firma.example", betreff: "noch einmal", rumpf: "noch einmal", laufId: lauf.id, benutzerId: techniker.id, }, }), ).rejects.toThrow(); }); it("meldet einen überfälligen Lauf nicht, wenn er abgeschlossen ist", async () => { const { kunde, techniker, versionId } = await aufbau(); await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id, faelligAm: new Date("2026-10-01T00:00:00Z"), status: LaufStatus.ABGESCHLOSSEN, }); expect(await ueberfaelligeEinreihen()).toBe(0); }); it("legt für einen Lauf ohne Zuweisung keine Zeile an", async () => { const { kunde, admin, versionId } = await aufbau(); const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: null }); expect(await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id)).toBe(0); }); it("reiht neben der Mail eine Webhook-Zeile ein, wenn einer eingerichtet ist", async () => { const { kunde, admin, techniker, versionId } = await aufbau(); await meldungseinstellungenSpeichern( { webhookUrl: "https://hook.firma.example/laufzettel", webhookGeheimnis: "geheim" }, admin.id, ); const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id }); await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id); const zustellungen = await testPrisma.zustellung.findMany({ orderBy: { weg: "asc" } }); expect(zustellungen.map((z) => z.weg)).toEqual([Zustellweg.MAIL, Zustellweg.WEBHOOK]); const hook = zustellungen.find((z) => z.weg === Zustellweg.WEBHOOK); // Ein Webhook gehoert keiner Person – die Datenbank verlangt das sogar. expect(hook?.benutzerId).toBeNull(); expect(JSON.parse(hook?.rumpf ?? "{}")).toMatchObject({ anlass: "LAUF_ZUGEWIESEN" }); }); }); /* ------------------------------------------------------------------ */ /* Senden */ /* ------------------------------------------------------------------ */ describe.runIf(datenbankVerfuegbar)("Die Warteschlange", () => { beforeAll(() => { process.env["APP_URL"] = "https://laufzettel.firma.example"; }); afterAll(() => { if (APP_URL_VORHER === undefined) delete process.env["APP_URL"]; else process.env["APP_URL"] = APP_URL_VORHER; }); beforeEach(async () => { await datenbankLeeren(); if (mailpitDa) await mailpitLeeren(); }); it.runIf(mailpitDa)("schickt alle vier Anlässe an die richtigen Empfänger", async () => { const { kunde, admin, techniker, versionId } = await aufbau(); await smtpEinrichten(); const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id, erstelltVonId: admin.id, faelligAm: new Date("2026-10-01T00:00:00Z"), status: LaufStatus.IN_ARBEIT, }); const quelle = await testPrisma.verzeichnisquelle.create({ data: { kundeId: kunde.id, name: "AD Zentrale", url: "ldaps://dc1.firma.test:636", bindDn: "cn=admin,dc=firma,dc=test", bindPasswortVerschluesselt: verschluesseln("egal"), basisDn: "dc=firma,dc=test", filter: "(objectClass=user)", attributZuordnung: {}, }, }); await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id); await ueberfaelligeEinreihen(); await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id); await abgleichMeldungEinreihen(quelle.id, "Verbindung abgelehnt"); const ergebnis = await warteschlangeAbarbeiten(); expect(ergebnis).toMatchObject({ fehlgeschlagen: 0, aufgegeben: 0 }); expect(ergebnis?.gesendet).toBe(4); const nachrichten = await mailpitNachrichten(); expect(nachrichten).toHaveLength(4); const nachEmpfaenger = new Map(); for (const nachricht of nachrichten) { for (const an of nachricht.To) { nachEmpfaenger.set(an.Address, [...(nachEmpfaenger.get(an.Address) ?? []), nachricht.Subject]); } } /* * Der Techniker: zugewiesen und überfällig. Der Admin: abgeschlossen (er hat * den Lauf angelegt) und der Abgleich. * * Geprueft wird **ohne Annahme ueber die Reihenfolge** — die haengt an der * Sortierung der Betreffe, und die hatte ich beim ersten Lauf falsch * geraten. Eine Sortierung ist hier auch nicht die Zusage; die Zusage ist, * wer was bekommt. */ const anTechniker = nachEmpfaenger.get("technik@firma.example") ?? []; expect(anTechniker).toHaveLength(2); expect(anTechniker.some((betreff) => betreff.includes("ist dir zugewiesen"))).toBe(true); expect(anTechniker.some((betreff) => betreff.includes("überfällig"))).toBe(true); const anAdmin = nachEmpfaenger.get("admin@firma.example") ?? []; expect(anAdmin).toHaveLength(2); expect(anAdmin.some((betreff) => betreff.includes("abgeschlossen"))).toBe(true); expect(anAdmin.some((betreff) => betreff.includes("Verzeichnis-Abgleich"))).toBe(true); // Und alle Zeilen stehen jetzt auf GESENDET, mit Zeitpunkt. const zeilen = await testPrisma.zustellung.findMany(); expect(zeilen).toHaveLength(4); for (const zeile of zeilen) { expect(zeile.zustand).toBe(Zustellzustand.GESENDET); expect(zeile.gesendetAm).toBeInstanceOf(Date); expect(zeile.versuche).toBe(1); } }); it.runIf(mailpitDa)("hat in keiner Nachricht ein erzeugtes Passwort", async () => { /* * Ueber **alle** Rumpftexte, wie in #012. Die Vorlage im Aufbau hat einen * Befehlsschritt mit `{{generiertes_passwort}}` und `New-ADUser` — kaeme * irgendwo ein Schrittinhalt in eine Mail, stuende es hier. */ const { kunde, admin, techniker, versionId } = await aufbau(); await smtpEinrichten(); const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id, erstelltVonId: admin.id, faelligAm: new Date("2026-10-01T00:00:00Z"), status: LaufStatus.IN_ARBEIT, }); await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id); await ueberfaelligeEinreihen(); await laufMeldungEinreihen(Meldeanlass.LAUF_ABGESCHLOSSEN, lauf.id, techniker.id); await warteschlangeAbarbeiten(); const nachrichten = await mailpitNachrichten(); expect(nachrichten.length).toBeGreaterThan(0); for (const nachricht of nachrichten) { const text = await mailpitText(nachricht.ID); expect(text).not.toContain("{{generiertes_passwort}}"); expect(text).not.toContain("New-ADUser"); expect(text).not.toContain("AccountPassword"); // Auch kein aufgeloestes Passwort: 20 Zeichen aus dem Alphabet von #012. expect(text).not.toMatch(/-AccountPassword/); } // Und in der Datenbank steht es auch nicht. const alsText = JSON.stringify(await testPrisma.zustellung.findMany()); expect(alsText).not.toContain("{{"); expect(alsText).not.toContain("New-ADUser"); }); it("wiederholt eine fehlgeschlagene Zustellung und macht sie danach sichtbar", async () => { const { kunde, admin, techniker, versionId } = await aufbau(); // **Kein** SMTP eingerichtet: jeder Versuch muss scheitern. const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id }); await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id); /* * **Nicht** ein festes Datum: `naechsterVersuchAm` steht beim Einreihen auf * `now()` der Datenbank. Ein Zeitpunkt in der Vergangenheit liesse den * Durchgang die Zeile gar nicht erst anfassen — beim ersten Lauf war der * Test deshalb rot, und zwar ohne Fehler im Code. */ const jetzt = new Date(Date.now() + 1_000); const erster = await warteschlangeAbarbeiten(jetzt); expect(erster).toMatchObject({ gesendet: 0, fehlgeschlagen: 1, aufgegeben: 0 }); let zeile = await testPrisma.zustellung.findFirstOrThrow(); expect(zeile.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN); expect(zeile.versuche).toBe(1); expect(zeile.letzterFehler).toContain("SMTP"); // Eine Minute später, nicht sofort. expect(zeile.naechsterVersuchAm.getTime()).toBe(jetzt.getTime() + 60_000); // Vor der Wartezeit fasst der Durchgang die Zeile nicht an. expect(await warteschlangeAbarbeiten(jetzt)).toMatchObject({ gesendet: 0, fehlgeschlagen: 0 }); expect((await testPrisma.zustellung.findFirstOrThrow()).versuche).toBe(1); // Danach die Versuche zwei bis vier, jeder nach seiner Wartezeit. for (const erwarteteWartezeit of WARTEZEITEN_MINUTEN.slice(1)) { const spaeter = new Date(zeile.naechsterVersuchAm.getTime() + 1_000); const vorherVersuche = zeile.versuche; await warteschlangeAbarbeiten(spaeter); zeile = await testPrisma.zustellung.findFirstOrThrow(); expect(zeile.versuche).toBe(vorherVersuche + 1); expect(zeile.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN); expect(zeile.naechsterVersuchAm.getTime() - spaeter.getTime()).toBe(erwarteteWartezeit * 60_000); } expect(zeile.versuche).toBe(WARTEZEITEN_MINUTEN.length); // Der fünfte Fehlschlag gibt auf – und bleibt sichtbar. const zuletzt = new Date(zeile.naechsterVersuchAm.getTime() + 1_000); const ergebnis = await warteschlangeAbarbeiten(zuletzt); expect(ergebnis).toMatchObject({ aufgegeben: 1 }); zeile = await testPrisma.zustellung.findFirstOrThrow(); expect(zeile.zustand).toBe(Zustellzustand.AUFGEGEBEN); expect(zeile.versuche).toBe(WARTEZEITEN_MINUTEN.length + 1); expect(zeile.letzterFehler).toBeTruthy(); // Und wird nicht mehr angefasst. expect(await warteschlangeAbarbeiten(new Date(zuletzt.getTime() + 86_400_000))).toMatchObject({ gesendet: 0, fehlgeschlagen: 0, aufgegeben: 0, }); }); it.runIf(mailpitDa)("schickt bei zwei gleichzeitigen Durchgängen nichts zweimal", async () => { const { kunde, admin, techniker, versionId } = await aufbau(); await smtpEinrichten(); const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id }); await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id); /* * Beide zugleich starten. Einer bekommt die Sperre, der andere `undefined` — * ohne sie bekaeme der Empfaenger bei zwei Instanzen jede Mail zweimal. */ const [eins, zwei] = await Promise.all([warteschlangeAbarbeiten(), warteschlangeAbarbeiten()]); const ergebnisse = [eins, zwei]; expect(ergebnisse.filter((e) => e === undefined)).toHaveLength(1); expect(ergebnisse.find((e) => e !== undefined)).toMatchObject({ gesendet: 1 }); expect(await mailpitNachrichten()).toHaveLength(1); }); }); /* ------------------------------------------------------------------ */ /* Webhook gegen einen echten Empfaenger */ /* ------------------------------------------------------------------ */ describe.runIf(datenbankVerfuegbar)("Der Webhook", () => { let server: Server | undefined; let port = 0; let empfangen: { kopf: string | undefined; rumpf: string }[] = []; let antwortStatus = 200; beforeAll(async () => { process.env["APP_URL"] = "https://laufzettel.firma.example"; server = createServer((anfrage, antwort) => { let rumpf = ""; anfrage.on("data", (stueck) => { rumpf += stueck; }); anfrage.on("end", () => { empfangen.push({ kopf: anfrage.headers[SIGNATUR_KOPF.toLowerCase()] as string | undefined, rumpf, }); antwort.writeHead(antwortStatus).end(antwortStatus === 200 ? "ok" : "kaputt"); }); }); await new Promise((fertig) => { server?.listen(0, "127.0.0.1", () => fertig()); }); const adresse = server?.address(); port = typeof adresse === "object" && adresse ? adresse.port : 0; }); afterAll(async () => { if (APP_URL_VORHER === undefined) delete process.env["APP_URL"]; else process.env["APP_URL"] = APP_URL_VORHER; await new Promise((fertig) => { server?.close(() => fertig()); }); }); beforeEach(async () => { await datenbankLeeren(); empfangen = []; antwortStatus = 200; }); afterEach(() => { antwortStatus = 200; }); it("kommt an, und die Signatur lässt sich mit dem Geheimnis nachrechnen", async () => { const { kunde, admin, techniker, versionId } = await aufbau(); await testPrisma.einstellung.update({ where: { id: "global" }, data: { webhookUrl: `http://127.0.0.1:${port}/laufzettel`, webhookGeheimnisVerschluesselt: verschluesseln("hook-geheim"), }, }); const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id }); await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id); // Nur die Webhook-Zeile: kein SMTP eingerichtet, die Mailzeile scheitert. const ergebnis = await warteschlangeAbarbeiten(); expect(ergebnis?.gesendet).toBe(1); expect(empfangen).toHaveLength(1); const nachricht = empfangen[0]; if (!nachricht?.kopf) throw new Error("Keine Signatur im Kopf."); const jetzt = Math.floor(Date.now() / 1000); expect(signaturPruefen("hook-geheim", nachricht.rumpf, nachricht.kopf, jetzt)).toEqual({ gueltig: true, }); // Mit einem falschen Geheimnis nicht. expect(signaturPruefen("falsch", nachricht.rumpf, nachricht.kopf, jetzt).gueltig).toBe(false); expect(JSON.parse(nachricht.rumpf)).toMatchObject({ anlass: "LAUF_ZUGEWIESEN", laufId: lauf.id, }); }); it("gilt als fehlgeschlagen, wenn die Gegenseite nicht mit 2xx antwortet", async () => { const { kunde, admin, techniker, versionId } = await aufbau(); await testPrisma.einstellung.update({ where: { id: "global" }, data: { webhookUrl: `http://127.0.0.1:${port}/laufzettel` }, }); const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id }); await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id); antwortStatus = 500; await warteschlangeAbarbeiten(new Date(Date.now() + 1_000)); const hook = await testPrisma.zustellung.findFirstOrThrow({ where: { weg: Zustellweg.WEBHOOK } }); expect(hook.zustand).toBe(Zustellzustand.FEHLGESCHLAGEN); expect(hook.letzterFehler).toContain("500"); }); it("geht ohne Geheimnis hinaus, dann aber ohne Signatur", async () => { /* * Ein Webhook ohne Geheimnis ist zulaessig — manche Gegenstellen haben keine * Pruefung. Dann fehlt die Kopfzeile, statt dass eine leere Signatur * Sicherheit vortaeuscht. */ const { kunde, admin, techniker, versionId } = await aufbau(); await testPrisma.einstellung.update({ where: { id: "global" }, data: { webhookUrl: `http://127.0.0.1:${port}/laufzettel` }, }); const lauf = await laufAnlegenRoh(versionId, kunde.id, { zugewiesenAnId: techniker.id }); await laufMeldungEinreihen(Meldeanlass.LAUF_ZUGEWIESEN, lauf.id, admin.id); await warteschlangeAbarbeiten(); expect(empfangen).toHaveLength(1); expect(empfangen[0]?.kopf).toBeUndefined(); }); });