import { describe, expect, it } from "vitest"; import { benachrichtigungSchema, empfaengerAus, gespeichertLesen, alsAnsicht, STANDARD_BENACHRICHTIGUNG, } from "@/lib/benachrichtigung/einstellungen"; import { ablaufStufe, anlassFaellig, cronBeschreiben, fingerabdruck, MAX_VERSUCHE, naechsterVersuch, webhookUrlPruefen, webhookZielKurz, } from "@/lib/benachrichtigung/regeln"; /** Regeln der Benachrichtigungen (#028): Stufen, Fingerabdruecke, Wiederholungen, Pruefungen. */ // 10:00 Uhr Berliner Zeit – weit weg von Mitternacht, damit Kalendertage eindeutig sind. const JETZT = new Date("2026-10-12T08:00:00Z"); const inTagen = (tage: number) => new Date(JETZT.getTime() + tage * 86_400_000); describe("Ablaufstufen 30/14/7", () => { it.each([ [45, null], [31, null], [30, 30], [20, 30], [15, 30], [14, 14], [8, 14], [7, 7], [1, 7], ])("in %i Tagen → Stufe %s", (tage, stufe) => { expect(ablaufStufe(inTagen(tage), JETZT)).toBe(stufe); }); it("abgelaufene Schlüssel haben keine Stufe mehr", () => { expect(ablaufStufe(JETZT, JETZT)).toBeNull(); expect(ablaufStufe(inTagen(-3), JETZT)).toBeNull(); }); it("je Stufe ein eigener Fingerabdruck, ein verlängerter Schlüssel beginnt von vorn", () => { const ablauf = inTagen(10); const a30 = fingerabdruck.ablauf("k1", 30, ablauf); const a14 = fingerabdruck.ablauf("k1", 14, ablauf); expect(a30).toBe("ablauf:k1:30:2026-10-22"); expect(a14).not.toBe(a30); expect(fingerabdruck.ablauf("k1", 14, inTagen(40))).not.toBe(a14); }); it("Unerreichbarkeit: ein Fingerabdruck je Ausfall", () => { const vorher = new Date("2026-10-10T00:30:00Z"); expect(fingerabdruck.unerreichbar("h1", vorher)).toBe(fingerabdruck.unerreichbar("h1", vorher)); expect(fingerabdruck.unerreichbar("h1", null)).toBe("unerreichbar:h1:nie"); expect(fingerabdruck.unerreichbar("h1", JETZT)).not.toBe(fingerabdruck.unerreichbar("h1", vorher)); }); }); describe("Deduplizierung und Wiederholung", () => { it("noch nie gemeldet: fällig, außer in einer reinen Wiederholungsrunde", () => { expect(anlassFaellig(undefined, JETZT)).toBe(true); expect(anlassFaellig(undefined, JETZT, true)).toBe(false); }); it("erfolgreich gemeldet: nie wieder", () => { const stand = { ergebnis: "GESENDET" as const, versuche: 1, naechsterVersuchAm: null }; expect(anlassFaellig(stand, JETZT)).toBe(false); expect(anlassFaellig(stand, JETZT, true)).toBe(false); }); it("fehlgeschlagen: erst nach der Wartezeit und begrenzt", () => { const spaeter = { ergebnis: "FEHLGESCHLAGEN" as const, versuche: 1, naechsterVersuchAm: inTagen(1) }; expect(anlassFaellig(spaeter, JETZT)).toBe(false); const faellig = { ...spaeter, naechsterVersuchAm: inTagen(-1) }; expect(anlassFaellig(faellig, JETZT, true)).toBe(true); expect(anlassFaellig({ ...faellig, versuche: MAX_VERSUCHE }, JETZT)).toBe(false); expect(anlassFaellig({ ...faellig, naechsterVersuchAm: null }, JETZT)).toBe(false); }); it("Wartezeit verdoppelt sich, nach dem letzten Versuch ist Schluss", () => { const minuten = (v: number) => ((naechsterVersuch(v, JETZT)?.getTime() ?? 0) - JETZT.getTime()) / 60_000; expect([1, 2, 3, 4].map(minuten)).toEqual([5, 10, 20, 40]); expect(naechsterVersuch(MAX_VERSUCHE, JETZT)).toBeNull(); }); }); describe("Webhook-Ziel", () => { it("nur https, http nur ausdrücklich", () => { expect(webhookUrlPruefen("https://hooks.example.de/x", false)).toBeNull(); expect(webhookUrlPruefen("http://10.0.0.5/hook", false)).toMatch(/Nur https/); expect(webhookUrlPruefen("http://10.0.0.5/hook", true)).toBeNull(); expect(webhookUrlPruefen("ftp://example.de", true)).toMatch(/Nur https/); expect(webhookUrlPruefen("kein url", false)).toMatch(/gültige/); expect(webhookUrlPruefen("https://a:b@example.de", false)).toMatch(/Zugangsdaten/); }); it("Kurzform ohne Pfad und Query", () => { expect(webhookZielKurz("https://hooks.example.de:8443/geheim/token?x=1")).toBe( "https://hooks.example.de:8443", ); }); }); describe("Zeitpläne lesbar", () => { it.each([ ["30 2 * * *", "täglich um 02:30 Uhr"], ["0 7 * * *", "täglich um 07:00 Uhr"], ["0 7 * * 1-5", "werktags um 07:00 Uhr"], ["*/5 * * * *", "alle 5 Minuten"], ["0 7 1 * *", null], ["quatsch", null], ])("%s → %s", (ausdruck, text) => { expect(cronBeschreiben(ausdruck)).toBe(text); }); }); describe("Einstellungen", () => { const gueltig = { emailAktiv: true, emailServer: "smtp.office365.com", emailPort: "587", emailSicherheit: "STARTTLS" as const, emailAbsender: "sshkv@example.de", emailBenutzer: "sshkv@example.de", emailPasswortEntfernen: false, emailEmpfaenger: ["a@example.de"], webhookAktiv: false, webhookUrl: "", webhookHttpErlaubt: false, webhookGeheimnisEntfernen: false, anlaesse: ["drift" as const], }; it("Vorbelegung für Microsoft 365", () => { expect(STANDARD_BENACHRICHTIGUNG.email).toMatchObject({ server: "smtp.office365.com", port: 587, sicherheit: "STARTTLS", }); }); it("Empfängerliste aus Zeilen und Kommas, ohne Dubletten", () => { expect(empfaengerAus("a@x.de, b@x.de\n\nb@x.de;c@x.de")).toEqual(["a@x.de", "b@x.de", "c@x.de"]); }); it("prüft Pflichtfelder nur für eingeschaltete Kanäle", () => { expect(benachrichtigungSchema.safeParse(gueltig).success).toBe(true); const ohne = benachrichtigungSchema.safeParse({ ...gueltig, emailEmpfaenger: [] }); expect(ohne.success).toBe(false); expect( benachrichtigungSchema.safeParse({ ...gueltig, emailAktiv: false, emailEmpfaenger: [], emailAbsender: "", }).success, ).toBe(true); }); it("lehnt http-Webhooks ohne Häkchen und falsche Adressen ab", () => { const http = benachrichtigungSchema.safeParse({ ...gueltig, webhookAktiv: true, webhookUrl: "http://10.0.0.1/", }); expect(http.success).toBe(false); expect(http.error?.issues[0]?.path).toEqual(["webhookUrl"]); expect( benachrichtigungSchema.safeParse({ ...gueltig, webhookAktiv: true, webhookUrl: "http://10.0.0.1/", webhookHttpErlaubt: true, }).success, ).toBe(true); const falsch = benachrichtigungSchema.safeParse({ ...gueltig, emailEmpfaenger: ["kein-adresse"] }); expect(falsch.error?.issues[0]?.message).toMatch(/keine gültige E-Mail-Adresse/); }); it("die Ansicht kennt nur „gesetzt“, nie den Wert", () => { const gespeichert = gespeichertLesen({ email: { passwort: "v1.chiffrat" }, webhook: { geheimnis: "v1.anderes" }, }); const ansicht = alsAnsicht(gespeichert); expect(ansicht.email.passwortGesetzt).toBe(true); expect(ansicht.webhook.geheimnisGesetzt).toBe(true); expect(JSON.stringify(ansicht)).not.toMatch(/chiffrat|anderes/); }); });