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>
195 lines
6.9 KiB
TypeScript
195 lines
6.9 KiB
TypeScript
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/);
|
||
});
|
||
});
|