Files
sshkeyvault/tests/benachrichtigung-regeln.test.ts
florian.wildeandClaude Opus 5.5 8f5b31c0cb 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>
2026-10-12 16:54:43 +02:00

195 lines
6.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/);
});
});