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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user