feat(benachrichtigung): E-Mail und Webhook bei Drift und Ablauf (#028)

Drift, ablaufende und abgelaufene Schluessel, unerreichbare Hosts und
geaenderte Host-Keys sollen auffallen, ohne dass jemand taeglich in die
Oberflaeche schaut. Versand per SMTP (Microsoft 365, STARTTLS) und per
Webhook mit HMAC-Signatur, eingestellt unter /einstellungen.

Die Anlaesse liest der Worker aus der Datenbank, statt sie im Abgleich
auszuloesen: der Abgleich-Ablauf bleibt unberuehrt, und ein verpasster
Lauf holt sich der naechste. Ein Versandprotokoll (Zustellung) mit
Anlass-Fingerabdruck verhindert Doppelmeldungen und haelt Fehlschlaege
fuer begrenzte Wiederholungen fest. Taeglich eine Zusammenfassung,
geaenderte Host-Keys im Fuenf-Minuten-Takt sofort.

Versand und Testnachricht laufen im Worker, damit der Webprozess keinen
Weg zu Mailserver oder Webhook-Ziel braucht. Passwort und Geheimnis
liegen nur verschluesselt in der Datenbank; die Seite zeigt "gesetzt".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:54:43 +02:00
co-authored by Claude Opus 5.5
parent 83bd9e0922
commit 8f5b31c0cb
26 changed files with 3243 additions and 12 deletions
+194
View File
@@ -0,0 +1,194 @@
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/);
});
});