import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { Nachricht } from "@/lib/benachrichtigung/nachrichten"; import { EINSTELLUNG_BENACHRICHTIGUNG } from "@/lib/benachrichtigung/einstellungen"; import { prisma } from "@/lib/db"; import { entschluesseln } from "@/lib/geheimnisse"; import { einstellungenSeite } from "@/server/queries/einstellungen"; import { benachrichtigungLesen, benachrichtigungSpeichern, testAnfordern, } from "@/server/services/benachrichtigung"; import { FachFehler } from "@/server/services/fachfehler"; import { hostKeyMeldungenVersenden, taktVersenden, testVersenden, zusammenfassungVersenden, } from "@/worker/benachrichtigung/versand"; import { datenbankLeeren } from "./hilfen/db"; import { warteschlangeStoppen } from "./hilfen/vorgang"; /** * Benachrichtigungen gegen die Testdatenbank (#028): Einstellungen mit verschluesselten * Geheimnissen und das Versandprotokoll gegen Doppelmeldungen. Die Versandwege selbst sind * hier ersetzt – gegen Mailpit und einen echten HTTP-Empfaenger laeuft * tests/integration/benachrichtigung.test.ts. */ const versand = vi.hoisted(() => ({ gesendet: [] as { kanal: string; nachricht: Nachricht }[], fehler: null as string | null, })); vi.mock("@/worker/benachrichtigung/kanaele", async (original) => { const echt = await original(); const senden = (kanal: string) => vi.fn(async (_zugang: unknown, nachricht: Nachricht) => { if (versand.fehler) throw new echt.ZustellFehler(versand.fehler); versand.gesendet.push({ kanal, nachricht }); }); return { ...echt, perEmailSenden: senden("EMAIL"), perWebhookSenden: senden("WEBHOOK") }; }); const AKTEUR = { id: "admin", name: "Ada Admin" }; const PASSWORT = "Sehr-Geheim-SMTP-42"; const GEHEIMNIS = "webhook-hmac-geheimnis-17"; const EINGABE = { emailAktiv: true, emailServer: "smtp.office365.com", emailPort: "587", emailSicherheit: "STARTTLS" as const, emailAbsender: "sshkv@example.de", emailBenutzer: "sshkv@example.de", emailPasswort: PASSWORT, emailPasswortEntfernen: false, emailEmpfaenger: ["admin@example.de"], webhookAktiv: false, webhookUrl: "https://hooks.example.de/geheimer-pfad", webhookHttpErlaubt: false, webhookGeheimnis: GEHEIMNIS, webhookGeheimnisEntfernen: false, anlaesse: ["drift", "ablauf", "abgelaufen", "unerreichbar", "hostkey"] as ( "drift" | "ablauf" | "abgelaufen" | "unerreichbar" | "hostkey" )[], }; afterAll(async () => { await warteschlangeStoppen(); }); beforeEach(async () => { await datenbankLeeren(); versand.gesendet = []; versand.fehler = null; }); async function alles(): Promise { const teile = await Promise.all([ prisma.einstellung.findMany(), prisma.protokoll.findMany(), prisma.zustellung.findMany(), prisma.vorgang.findMany(), ]); return JSON.stringify(teile, (_k, w: unknown) => (typeof w === "bigint" ? w.toString() : w)); } describe("Einstellungen", () => { it("speichert Passwort und Geheimnis nur verschlüsselt", async () => { await benachrichtigungSpeichern(EINGABE, AKTEUR); const gespeichert = await benachrichtigungLesen(); expect(gespeichert.email.passwort).toMatch(/^v1\./); expect(entschluesseln(gespeichert.email.passwort!)).toBe(PASSWORT); expect(entschluesseln(gespeichert.webhook.geheimnis!)).toBe(GEHEIMNIS); const text = await alles(); expect(text).not.toContain(PASSWORT); expect(text).not.toContain(GEHEIMNIS); const protokoll = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "einstellungen.benachrichtigungen.geaendert" }, }); expect(protokoll.details).toMatchObject({ smtpZugangGeaendert: true, signaturSchluesselGeaendert: true }); }); it("die Seite liefert nie Klartext oder Chiffrat", async () => { await benachrichtigungSpeichern(EINGABE, AKTEUR); const gespeichert = await benachrichtigungLesen(); const seite = JSON.stringify(await einstellungenSeite()); expect(seite).not.toContain(PASSWORT); expect(seite).not.toContain(GEHEIMNIS); expect(seite).not.toContain(gespeichert.email.passwort!); expect(seite).not.toContain(gespeichert.webhook.geheimnis!); expect(seite).toContain('"passwortGesetzt":true'); }); it("leeres Feld behält, Häkchen entfernt", async () => { await benachrichtigungSpeichern(EINGABE, AKTEUR); const vorher = await benachrichtigungLesen(); await benachrichtigungSpeichern( { ...EINGABE, emailPasswort: undefined, webhookGeheimnis: undefined }, AKTEUR, ); const behalten = await benachrichtigungLesen(); expect(behalten.email.passwort).toBe(vorher.email.passwort); expect(behalten.webhook.geheimnis).toBe(vorher.webhook.geheimnis); await benachrichtigungSpeichern( { ...EINGABE, emailPasswort: undefined, webhookGeheimnis: undefined, emailPasswortEntfernen: true }, AKTEUR, ); const entfernt = await benachrichtigungLesen(); expect(entfernt.email.passwort).toBeNull(); expect(entfernt.webhook.geheimnis).toBe(vorher.webhook.geheimnis); }); it("ungültige Eingaben werden zu Feldfehlern", async () => { const fehler = await benachrichtigungSpeichern( { ...EINGABE, webhookAktiv: true, webhookUrl: "http://intern/hook" }, AKTEUR, ).catch((e: unknown) => e); expect(fehler).toBeInstanceOf(FachFehler); expect((fehler as FachFehler).feldfehler["webhookUrl"]).toMatch(/Nur https/); expect(await prisma.einstellung.count({ where: { schluessel: EINSTELLUNG_BENACHRICHTIGUNG } })).toBe(0); }); it("Testnachricht nur für vollständig eingetragene Kanäle", async () => { const admin = await prisma.benutzer.create({ data: { name: "Ada Admin", rolle: "ADMIN" } }); await expect(testAnfordern("EMAIL", admin.id)).rejects.toThrow(/Erst Server/); await benachrichtigungSpeichern(EINGABE, AKTEUR); const id = await testAnfordern("EMAIL", admin.id); const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id } }); expect(vorgang).toMatchObject({ art: "BENACHRICHTIGUNG_TEST", eingabe: { kanal: "EMAIL" } }); }); }); // ---------------------------------------------------------------- Versandprotokoll const JETZT = new Date("2026-10-12T05:00:00Z"); const spaeter = (minuten: number) => new Date(JETZT.getTime() + minuten * 60_000); let zaehler = 0; async function hostMitDrift() { zaehler += 1; const host = await prisma.host.create({ data: { name: `srv-${zaehler}`, adresse: `10.0.0.${zaehler}`, bootstrapAm: JETZT, erreichbar: true }, }); const abweichung = await prisma.abweichung.create({ data: { hostId: host.id, art: "UNBEKANNTER_SCHLUESSEL", zielbenutzer: "root", details: { text: "fremd" }, }, }); return { host, abweichung }; } async function schluessel(ablaufAm: Date) { zaehler += 1; return prisma.schluessel.create({ data: { name: `Schlüssel ${zaehler}`, slug: `s-${zaehler}`, typ: "ED25519", kategorie: "DIENST", publicKey: "ssh-ed25519 AAAA test", fingerprint: `SHA256:t${zaehler}`, kommentar: "t", ablaufAm, }, }); } describe("Versandprotokoll", () => { beforeEach(async () => { await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: true }, AKTEUR); versand.gesendet = []; }); it("meldet eine Abweichung je Kanal genau einmal", async () => { const { abweichung } = await hostMitDrift(); expect(await zusammenfassungVersenden(JETZT)).toEqual({ gesendet: 2, fehlgeschlagen: 0 }); expect(versand.gesendet.map((g) => g.kanal).sort()).toEqual(["EMAIL", "WEBHOOK"]); expect(await zusammenfassungVersenden(spaeter(60))).toEqual({ gesendet: 0, fehlgeschlagen: 0 }); const zeilen = await prisma.zustellung.findMany({ where: { anlass: `drift:${abweichung.id}` } }); expect(zeilen.map((z) => z.ergebnis)).toEqual(["GESENDET", "GESENDET"]); const protokoll = await prisma.protokoll.findMany({ where: { aktion: "benachrichtigung.gesendet" } }); expect(protokoll).toHaveLength(2); expect(JSON.stringify(protokoll, (_k, w: unknown) => (typeof w === "bigint" ? 0 : w))).not.toContain( "geheimer-pfad", ); }); it("eine neue Abweichung kommt in die nächste Zusammenfassung, die alte nicht noch einmal", async () => { await hostMitDrift(); await zusammenfassungVersenden(JETZT); versand.gesendet = []; const { host } = await hostMitDrift(); await zusammenfassungVersenden(spaeter(24 * 60)); const email = versand.gesendet.find((g) => g.kanal === "EMAIL")!.nachricht; expect(email.betreff).toContain("1 neue Abweichung"); expect(email.text).toContain(host.name); expect(email.text).not.toContain("srv-1 "); }); it("wiederholt Fehlschläge begrenzt und nur nach der Wartezeit", async () => { await hostMitDrift(); versand.fehler = "SMTP: 535 Authentication unsuccessful"; expect(await zusammenfassungVersenden(JETZT)).toEqual({ gesendet: 0, fehlgeschlagen: 2 }); const zeile = await prisma.zustellung.findFirstOrThrow({ where: { kanal: "EMAIL" } }); expect(zeile).toMatchObject({ ergebnis: "FEHLGESCHLAGEN", versuche: 1, fehler: "SMTP: 535 Authentication unsuccessful", }); expect(zeile.naechsterVersuchAm).toEqual(spaeter(5)); expect(await prisma.protokoll.count({ where: { aktion: "benachrichtigung.fehlgeschlagen" } })).toBe(2); // Vor Ablauf der Wartezeit nichts; danach holt der Takt nach. expect(await taktVersenden(spaeter(2))).toEqual({ gesendet: 0, fehlgeschlagen: 0 }); versand.fehler = null; expect(await taktVersenden(spaeter(6))).toEqual({ gesendet: 2, fehlgeschlagen: 0 }); expect(await prisma.zustellung.findFirstOrThrow({ where: { kanal: "EMAIL" } })).toMatchObject({ ergebnis: "GESENDET", versuche: 2, naechsterVersuchAm: null, }); // Der Takt verschickt nichts Neues – das wartet auf die Zusammenfassung. await hostMitDrift(); expect(await taktVersenden(spaeter(20))).toEqual({ gesendet: 0, fehlgeschlagen: 0 }); }); it("gibt nach fünf Versuchen auf", async () => { await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: false }, AKTEUR); await hostMitDrift(); versand.fehler = "kaputt"; await zusammenfassungVersenden(JETZT); for (const minuten of [10, 30, 60, 120, 240, 480]) await taktVersenden(spaeter(minuten)); const zeile = await prisma.zustellung.findFirstOrThrow(); expect(zeile).toMatchObject({ versuche: 5, naechsterVersuchAm: null, ergebnis: "FEHLGESCHLAGEN" }); }); it("Ablauf: je Stufe genau eine Meldung", async () => { await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: false }, AKTEUR); const tag = 24 * 60; await schluessel(spaeter(20 * tag)); await zusammenfassungVersenden(JETZT); await zusammenfassungVersenden(spaeter(1 * tag)); await zusammenfassungVersenden(spaeter(7 * tag)); // noch 13 Tage → Stufe 14 await zusammenfassungVersenden(spaeter(8 * tag)); await zusammenfassungVersenden(spaeter(14 * tag)); // noch 6 Tage → Stufe 7 await zusammenfassungVersenden(spaeter(19 * tag)); const anlaesse = (await prisma.zustellung.findMany({ orderBy: { zeitpunkt: "asc" } })).map((z) => z.anlass.split(":").slice(0, 3).join(":"), ); expect(anlaesse).toEqual([ expect.stringMatching(/^ablauf:.+:30$/), expect.stringMatching(/^ablauf:.+:14$/), expect.stringMatching(/^ablauf:.+:7$/), ]); expect(versand.gesendet).toHaveLength(3); }); it("abgelaufen und noch ausgerollt; unerreichbar einmal je Ausfall", async () => { await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: false }, AKTEUR); const host = await prisma.host.create({ data: { name: "srv-weg", adresse: "10.9.9.9", bootstrapAm: JETZT, erreichbar: false }, }); const alt = await schluessel(spaeter(-60)); await prisma.publicVerwendung.create({ data: { schluesselId: alt.id, hostId: host.id, zielbenutzer: "root", zustand: "AUSGEROLLT" }, }); await zusammenfassungVersenden(JETZT); const text = versand.gesendet[0]!.nachricht.text; expect(text).toContain("Hosts nicht erreichbar:"); expect(text).toContain("srv-weg (10.9.9.9) – noch nie erfolgreich abgeglichen"); expect(text).toContain(`${alt.name} – abgelaufen am 12.10.2026, noch auf srv-weg`); versand.gesendet = []; await zusammenfassungVersenden(spaeter(24 * 60)); expect(versand.gesendet).toHaveLength(0); // Erholt und wieder ausgefallen: neuer Ausfall, neue Meldung. await prisma.host.update({ where: { id: host.id }, data: { letzterAbgleichAm: spaeter(25 * 60) } }); await zusammenfassungVersenden(spaeter(48 * 60)); expect(versand.gesendet).toHaveLength(1); expect(versand.gesendet[0]!.nachricht.betreff).toContain("1 Host nicht erreichbar"); }); it("ausgeschaltete Anlässe werden nicht gemeldet", async () => { await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: false, anlaesse: ["ablauf"] }, AKTEUR); await hostMitDrift(); expect(await zusammenfassungVersenden(JETZT)).toEqual({ gesendet: 0, fehlgeschlagen: 0 }); }); it("geänderter Host-Key: sofort im Takt, nicht in der Zusammenfassung, nicht als unerreichbar", async () => { const host = await prisma.host.create({ data: { name: "srv-mitm", adresse: "10.6.6.6", bootstrapAm: JETZT, erreichbar: false }, }); await prisma.abweichung.create({ data: { hostId: host.id, art: "HOSTKEY_GEAENDERT", fingerprint: "SHA256:neu", details: { erwartet: "SHA256:alt", algorithmus: "ssh-ed25519" }, }, }); expect(await zusammenfassungVersenden(JETZT)).toEqual({ gesendet: 0, fehlgeschlagen: 0 }); expect(await taktVersenden(JETZT)).toEqual({ gesendet: 2, fehlgeschlagen: 0 }); expect(versand.gesendet[0]!.nachricht.betreff).toBe( "[sshkv] DRINGEND: Host-Key von srv-mitm hat sich geändert", ); expect(await hostKeyMeldungenVersenden(spaeter(5))).toEqual({ gesendet: 0, fehlgeschlagen: 0 }); }); it("Testnachrichten landen im Protokoll und werden nicht wiederholt", async () => { versand.fehler = "Webhook antwortet mit HTTP 500."; expect(await testVersenden("WEBHOOK", AKTEUR)).toBe("Webhook antwortet mit HTTP 500."); const zeile = await prisma.zustellung.findFirstOrThrow(); expect(zeile.anlass).toMatch(/^test:/); expect(zeile.naechsterVersuchAm).toBeNull(); const protokoll = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "benachrichtigung.fehlgeschlagen" }, }); expect(protokoll).toMatchObject({ akteurName: "Ada Admin", details: { ziel: "https://hooks.example.de" }, }); const seite = await einstellungenSeite(); expect(seite.zustellungen[0]).toMatchObject({ kanal: "WEBHOOK", ergebnis: "FEHLGESCHLAGEN", anlaesse: 1, }); }); });