feat(alerts): Benachrichtigungen per E-Mail und Webhook (#012)
SMTP mit STARTTLS fuer Microsoft 365, Webhook mit HMAC-Signatur, taegliche oder woechentliche Zusammenfassung, Versandprotokoll und Testversand. Keine Doppelmeldungen dank Pruefung gegen das Protokoll. 134 Tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { perWebhookSenden, VersandFehler } from "@/server/notifications/kanaele";
|
||||
import { alertNachricht, zusammenfassungNachricht } from "@/server/notifications/nachrichten";
|
||||
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
const APP_URL = "https://proxledger.example.de";
|
||||
|
||||
describe("Nachrichten", () => {
|
||||
it("baut eine Warnungs-Nachricht mit Betreff, Text und Daten", () => {
|
||||
const nachricht = alertNachricht(
|
||||
{
|
||||
id: "a1",
|
||||
regel: "subscription-laeuft-ab",
|
||||
schwere: "GEFAHR",
|
||||
titel: "Subscription läuft in 7 Tagen ab",
|
||||
beschreibung: "pve-01 · Brandl GmbH",
|
||||
hostId: "h1",
|
||||
},
|
||||
APP_URL,
|
||||
);
|
||||
|
||||
expect(nachricht.betreff).toBe("[ProxLedger] Gefahr: Subscription läuft in 7 Tagen ab");
|
||||
expect(nachricht.text).toContain("pve-01 · Brandl GmbH");
|
||||
expect(nachricht.text).toContain(`${APP_URL}/warnungen`);
|
||||
expect(nachricht.html).toContain("ProxLedger");
|
||||
expect(nachricht.daten).toMatchObject({ art: "warnung", alertId: "a1", schwere: "GEFAHR" });
|
||||
});
|
||||
|
||||
it("baut eine Zusammenfassung mit Kennzahlen", () => {
|
||||
const nachricht = zusammenfassungNachricht(
|
||||
{
|
||||
zeitraum: "taeglich",
|
||||
offeneAlerts: [
|
||||
{
|
||||
id: "a1",
|
||||
regel: "keine-meldung",
|
||||
schwere: "WARNUNG",
|
||||
titel: "Host meldet sich nicht",
|
||||
beschreibung: "pve-02",
|
||||
hostId: "h2",
|
||||
},
|
||||
],
|
||||
neueAlerts: [],
|
||||
baldAblaufend: [
|
||||
{ hostname: "pve-01", kunde: "Brandl GmbH", datum: new Date("2026-09-24"), tage: 7 },
|
||||
],
|
||||
offeneRechnungspunkte: 3,
|
||||
hostsOhneMeldung: 1,
|
||||
},
|
||||
APP_URL,
|
||||
);
|
||||
|
||||
expect(nachricht.betreff).toBe("[ProxLedger] Tagesübersicht: 1 offene Warnungen");
|
||||
expect(nachricht.text).toContain("Offene Rechnungspunkte: 3");
|
||||
expect(nachricht.text).toContain("pve-01 (Brandl GmbH) – 24.09.2026, in 7 Tagen");
|
||||
expect(nachricht.daten).toMatchObject({ art: "zusammenfassung", zeitraum: "taeglich" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Webhook", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("schickt JSON und signiert es mit dem Geheimnis", async () => {
|
||||
const aufrufe: { url: string; optionen: RequestInit }[] = [];
|
||||
vi.stubGlobal("fetch", async (url: string, optionen: RequestInit) => {
|
||||
aufrufe.push({ url, optionen });
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
|
||||
const nachricht = alertNachricht(
|
||||
{
|
||||
id: "a1",
|
||||
regel: "status-nicht-aktiv",
|
||||
schwere: "GEFAHR",
|
||||
titel: "Subscription abgelaufen",
|
||||
beschreibung: "pve-01",
|
||||
hostId: "h1",
|
||||
},
|
||||
APP_URL,
|
||||
);
|
||||
|
||||
await perWebhookSenden({ url: "https://hook.example.de/x", geheimnis: "s3cret" }, nachricht);
|
||||
|
||||
expect(aufrufe).toHaveLength(1);
|
||||
const kopfzeilen = aufrufe[0].optionen.headers as Record<string, string>;
|
||||
const koerper = aufrufe[0].optionen.body as string;
|
||||
|
||||
expect(kopfzeilen["Content-Type"]).toBe("application/json");
|
||||
expect(JSON.parse(koerper)).toMatchObject({ art: "warnung", regel: "status-nicht-aktiv" });
|
||||
expect(kopfzeilen["X-ProxLedger-Signatur"]).toBe(
|
||||
`sha256=${createHmac("sha256", "s3cret").update(koerper).digest("hex")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("signiert ohne Geheimnis nicht", async () => {
|
||||
const aufrufe: RequestInit[] = [];
|
||||
vi.stubGlobal("fetch", async (_url: string, optionen: RequestInit) => {
|
||||
aufrufe.push(optionen);
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
|
||||
await perWebhookSenden(
|
||||
{ url: "https://hook.example.de/x" },
|
||||
{ betreff: "t", text: "t", html: "t", daten: {} },
|
||||
);
|
||||
|
||||
expect((aufrufe[0].headers as Record<string, string>)["X-ProxLedger-Signatur"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("meldet einen Fehlerstatus des Empfängers", async () => {
|
||||
vi.stubGlobal("fetch", async () => new Response("nope", { status: 500 }));
|
||||
|
||||
await expect(
|
||||
perWebhookSenden(
|
||||
{ url: "https://hook.example.de/x" },
|
||||
{ betreff: "t", text: "t", html: "t", daten: {} },
|
||||
),
|
||||
).rejects.toThrow(VersandFehler);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Versand", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
vi.stubGlobal("fetch", async () => new Response("ok", { status: 200 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testPrisma.$disconnect();
|
||||
});
|
||||
|
||||
async function webhookKanal() {
|
||||
return testPrisma.benachrichtigungskanal.create({
|
||||
data: {
|
||||
typ: "WEBHOOK",
|
||||
name: "Test-Hook",
|
||||
aktiv: true,
|
||||
konfiguration: { url: "https://hook.example.de/x" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function alert(fingerprint = "status-nicht-aktiv:h1") {
|
||||
return testPrisma.alert.create({
|
||||
data: {
|
||||
fingerprint,
|
||||
regel: "status-nicht-aktiv",
|
||||
schwere: "GEFAHR",
|
||||
titel: "Subscription abgelaufen",
|
||||
beschreibung: "pve-01",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("verschickt eine Warnung und protokolliert den Versand", async () => {
|
||||
await webhookKanal();
|
||||
const eintrag = await alert();
|
||||
|
||||
const ergebnis = await neueAlertsMelden([eintrag.fingerprint]);
|
||||
|
||||
expect(ergebnis.gesendet).toBe(1);
|
||||
const protokoll = await testPrisma.benachrichtigung.findMany();
|
||||
expect(protokoll).toHaveLength(1);
|
||||
expect(protokoll[0].status).toBe("GESENDET");
|
||||
expect(protokoll[0].alertId).toBe(eintrag.id);
|
||||
});
|
||||
|
||||
it("meldet denselben offenen Alert kein zweites Mal", async () => {
|
||||
await webhookKanal();
|
||||
const eintrag = await alert();
|
||||
|
||||
await neueAlertsMelden([eintrag.fingerprint]);
|
||||
const zweiter = await neueAlertsMelden([eintrag.fingerprint]);
|
||||
|
||||
expect(zweiter.gesendet).toBe(0);
|
||||
expect(zweiter.uebersprungen).toBe(1);
|
||||
expect(await testPrisma.benachrichtigung.count({ where: { status: "GESENDET" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("verschickt nichts, solange kein Kanal eingerichtet ist", async () => {
|
||||
const eintrag = await alert();
|
||||
const ergebnis = await neueAlertsMelden([eintrag.fingerprint]);
|
||||
|
||||
expect(ergebnis.gesendet).toBe(0);
|
||||
expect(await testPrisma.benachrichtigung.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("protokolliert einen fehlgeschlagenen Versand mit Grund", async () => {
|
||||
await webhookKanal();
|
||||
const eintrag = await alert();
|
||||
vi.stubGlobal("fetch", async () => new Response("kaputt", { status: 503 }));
|
||||
|
||||
const ergebnis = await neueAlertsMelden([eintrag.fingerprint]);
|
||||
|
||||
expect(ergebnis.fehlgeschlagen).toBe(1);
|
||||
const protokoll = await testPrisma.benachrichtigung.findFirstOrThrow();
|
||||
expect(protokoll.status).toBe("FEHLGESCHLAGEN");
|
||||
expect(protokoll.fehler).toContain("503");
|
||||
});
|
||||
|
||||
it("versucht einen fehlgeschlagenen Versand beim nächsten Lauf erneut", async () => {
|
||||
await webhookKanal();
|
||||
const eintrag = await alert();
|
||||
|
||||
vi.stubGlobal("fetch", async () => new Response("kaputt", { status: 503 }));
|
||||
await neueAlertsMelden([eintrag.fingerprint]);
|
||||
|
||||
vi.stubGlobal("fetch", async () => new Response("ok", { status: 200 }));
|
||||
const zweiter = await neueAlertsMelden([eintrag.fingerprint]);
|
||||
|
||||
expect(zweiter.gesendet).toBe(1);
|
||||
});
|
||||
|
||||
it("verschickt die Zusammenfassung mit den aktuellen Zahlen", async () => {
|
||||
await webhookKanal();
|
||||
await alert();
|
||||
|
||||
const ergebnis = await zusammenfassungSenden("taeglich", new Date("2026-09-17T07:00:00Z"));
|
||||
|
||||
expect(ergebnis.gesendet).toBe(1);
|
||||
const protokoll = await testPrisma.benachrichtigung.findFirstOrThrow();
|
||||
expect(protokoll.betreff).toContain("Tagesübersicht");
|
||||
// Die Zusammenfassung hängt an keinem einzelnen Alert.
|
||||
expect(protokoll.alertId).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user