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,354 @@
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Nachricht } from "@/lib/benachrichtigung/nachrichten";
|
||||
import { EINSTELLUNG_BENACHRICHTIGUNG } from "@/lib/benachrichtigung/einstellungen";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { entschluesseln } from "@/lib/geheimnisse";
|
||||
import { einstellungenSeite } from "@/server/queries/einstellungen";
|
||||
import {
|
||||
benachrichtigungLesen,
|
||||
benachrichtigungSpeichern,
|
||||
testAnfordern,
|
||||
} from "@/server/services/benachrichtigung";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import {
|
||||
hostKeyMeldungenVersenden,
|
||||
taktVersenden,
|
||||
testVersenden,
|
||||
zusammenfassungVersenden,
|
||||
} from "@/worker/benachrichtigung/versand";
|
||||
import { datenbankLeeren } from "./hilfen/db";
|
||||
import { warteschlangeStoppen } from "./hilfen/vorgang";
|
||||
|
||||
/**
|
||||
* Benachrichtigungen gegen die Testdatenbank (#028): Einstellungen mit verschluesselten
|
||||
* Geheimnissen und das Versandprotokoll gegen Doppelmeldungen. Die Versandwege selbst sind
|
||||
* hier ersetzt – gegen Mailpit und einen echten HTTP-Empfaenger laeuft
|
||||
* tests/integration/benachrichtigung.test.ts.
|
||||
*/
|
||||
const versand = vi.hoisted(() => ({
|
||||
gesendet: [] as { kanal: string; nachricht: Nachricht }[],
|
||||
fehler: null as string | null,
|
||||
}));
|
||||
vi.mock("@/worker/benachrichtigung/kanaele", async (original) => {
|
||||
const echt = await original<typeof import("@/worker/benachrichtigung/kanaele")>();
|
||||
const senden = (kanal: string) =>
|
||||
vi.fn(async (_zugang: unknown, nachricht: Nachricht) => {
|
||||
if (versand.fehler) throw new echt.ZustellFehler(versand.fehler);
|
||||
versand.gesendet.push({ kanal, nachricht });
|
||||
});
|
||||
return { ...echt, perEmailSenden: senden("EMAIL"), perWebhookSenden: senden("WEBHOOK") };
|
||||
});
|
||||
|
||||
const AKTEUR = { id: "admin", name: "Ada Admin" };
|
||||
const PASSWORT = "Sehr-Geheim-SMTP-42";
|
||||
const GEHEIMNIS = "webhook-hmac-geheimnis-17";
|
||||
|
||||
const EINGABE = {
|
||||
emailAktiv: true,
|
||||
emailServer: "smtp.office365.com",
|
||||
emailPort: "587",
|
||||
emailSicherheit: "STARTTLS" as const,
|
||||
emailAbsender: "sshkv@example.de",
|
||||
emailBenutzer: "sshkv@example.de",
|
||||
emailPasswort: PASSWORT,
|
||||
emailPasswortEntfernen: false,
|
||||
emailEmpfaenger: ["admin@example.de"],
|
||||
webhookAktiv: false,
|
||||
webhookUrl: "https://hooks.example.de/geheimer-pfad",
|
||||
webhookHttpErlaubt: false,
|
||||
webhookGeheimnis: GEHEIMNIS,
|
||||
webhookGeheimnisEntfernen: false,
|
||||
anlaesse: ["drift", "ablauf", "abgelaufen", "unerreichbar", "hostkey"] as (
|
||||
"drift" | "ablauf" | "abgelaufen" | "unerreichbar" | "hostkey"
|
||||
)[],
|
||||
};
|
||||
|
||||
afterAll(async () => {
|
||||
await warteschlangeStoppen();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
versand.gesendet = [];
|
||||
versand.fehler = null;
|
||||
});
|
||||
|
||||
async function alles(): Promise<string> {
|
||||
const teile = await Promise.all([
|
||||
prisma.einstellung.findMany(),
|
||||
prisma.protokoll.findMany(),
|
||||
prisma.zustellung.findMany(),
|
||||
prisma.vorgang.findMany(),
|
||||
]);
|
||||
return JSON.stringify(teile, (_k, w: unknown) => (typeof w === "bigint" ? w.toString() : w));
|
||||
}
|
||||
|
||||
describe("Einstellungen", () => {
|
||||
it("speichert Passwort und Geheimnis nur verschlüsselt", async () => {
|
||||
await benachrichtigungSpeichern(EINGABE, AKTEUR);
|
||||
const gespeichert = await benachrichtigungLesen();
|
||||
expect(gespeichert.email.passwort).toMatch(/^v1\./);
|
||||
expect(entschluesseln(gespeichert.email.passwort!)).toBe(PASSWORT);
|
||||
expect(entschluesseln(gespeichert.webhook.geheimnis!)).toBe(GEHEIMNIS);
|
||||
const text = await alles();
|
||||
expect(text).not.toContain(PASSWORT);
|
||||
expect(text).not.toContain(GEHEIMNIS);
|
||||
const protokoll = await prisma.protokoll.findFirstOrThrow({
|
||||
where: { aktion: "einstellungen.benachrichtigungen.geaendert" },
|
||||
});
|
||||
expect(protokoll.details).toMatchObject({ smtpZugangGeaendert: true, signaturSchluesselGeaendert: true });
|
||||
});
|
||||
|
||||
it("die Seite liefert nie Klartext oder Chiffrat", async () => {
|
||||
await benachrichtigungSpeichern(EINGABE, AKTEUR);
|
||||
const gespeichert = await benachrichtigungLesen();
|
||||
const seite = JSON.stringify(await einstellungenSeite());
|
||||
expect(seite).not.toContain(PASSWORT);
|
||||
expect(seite).not.toContain(GEHEIMNIS);
|
||||
expect(seite).not.toContain(gespeichert.email.passwort!);
|
||||
expect(seite).not.toContain(gespeichert.webhook.geheimnis!);
|
||||
expect(seite).toContain('"passwortGesetzt":true');
|
||||
});
|
||||
|
||||
it("leeres Feld behält, Häkchen entfernt", async () => {
|
||||
await benachrichtigungSpeichern(EINGABE, AKTEUR);
|
||||
const vorher = await benachrichtigungLesen();
|
||||
await benachrichtigungSpeichern(
|
||||
{ ...EINGABE, emailPasswort: undefined, webhookGeheimnis: undefined },
|
||||
AKTEUR,
|
||||
);
|
||||
const behalten = await benachrichtigungLesen();
|
||||
expect(behalten.email.passwort).toBe(vorher.email.passwort);
|
||||
expect(behalten.webhook.geheimnis).toBe(vorher.webhook.geheimnis);
|
||||
await benachrichtigungSpeichern(
|
||||
{ ...EINGABE, emailPasswort: undefined, webhookGeheimnis: undefined, emailPasswortEntfernen: true },
|
||||
AKTEUR,
|
||||
);
|
||||
const entfernt = await benachrichtigungLesen();
|
||||
expect(entfernt.email.passwort).toBeNull();
|
||||
expect(entfernt.webhook.geheimnis).toBe(vorher.webhook.geheimnis);
|
||||
});
|
||||
|
||||
it("ungültige Eingaben werden zu Feldfehlern", async () => {
|
||||
const fehler = await benachrichtigungSpeichern(
|
||||
{ ...EINGABE, webhookAktiv: true, webhookUrl: "http://intern/hook" },
|
||||
AKTEUR,
|
||||
).catch((e: unknown) => e);
|
||||
expect(fehler).toBeInstanceOf(FachFehler);
|
||||
expect((fehler as FachFehler).feldfehler["webhookUrl"]).toMatch(/Nur https/);
|
||||
expect(await prisma.einstellung.count({ where: { schluessel: EINSTELLUNG_BENACHRICHTIGUNG } })).toBe(0);
|
||||
});
|
||||
|
||||
it("Testnachricht nur für vollständig eingetragene Kanäle", async () => {
|
||||
const admin = await prisma.benutzer.create({ data: { name: "Ada Admin", rolle: "ADMIN" } });
|
||||
await expect(testAnfordern("EMAIL", admin.id)).rejects.toThrow(/Erst Server/);
|
||||
await benachrichtigungSpeichern(EINGABE, AKTEUR);
|
||||
const id = await testAnfordern("EMAIL", admin.id);
|
||||
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id } });
|
||||
expect(vorgang).toMatchObject({ art: "BENACHRICHTIGUNG_TEST", eingabe: { kanal: "EMAIL" } });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- Versandprotokoll
|
||||
|
||||
const JETZT = new Date("2026-10-12T05:00:00Z");
|
||||
const spaeter = (minuten: number) => new Date(JETZT.getTime() + minuten * 60_000);
|
||||
let zaehler = 0;
|
||||
|
||||
async function hostMitDrift() {
|
||||
zaehler += 1;
|
||||
const host = await prisma.host.create({
|
||||
data: { name: `srv-${zaehler}`, adresse: `10.0.0.${zaehler}`, bootstrapAm: JETZT, erreichbar: true },
|
||||
});
|
||||
const abweichung = await prisma.abweichung.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
art: "UNBEKANNTER_SCHLUESSEL",
|
||||
zielbenutzer: "root",
|
||||
details: { text: "fremd" },
|
||||
},
|
||||
});
|
||||
return { host, abweichung };
|
||||
}
|
||||
|
||||
async function schluessel(ablaufAm: Date) {
|
||||
zaehler += 1;
|
||||
return prisma.schluessel.create({
|
||||
data: {
|
||||
name: `Schlüssel ${zaehler}`,
|
||||
slug: `s-${zaehler}`,
|
||||
typ: "ED25519",
|
||||
kategorie: "DIENST",
|
||||
publicKey: "ssh-ed25519 AAAA test",
|
||||
fingerprint: `SHA256:t${zaehler}`,
|
||||
kommentar: "t",
|
||||
ablaufAm,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("Versandprotokoll", () => {
|
||||
beforeEach(async () => {
|
||||
await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: true }, AKTEUR);
|
||||
versand.gesendet = [];
|
||||
});
|
||||
|
||||
it("meldet eine Abweichung je Kanal genau einmal", async () => {
|
||||
const { abweichung } = await hostMitDrift();
|
||||
expect(await zusammenfassungVersenden(JETZT)).toEqual({ gesendet: 2, fehlgeschlagen: 0 });
|
||||
expect(versand.gesendet.map((g) => g.kanal).sort()).toEqual(["EMAIL", "WEBHOOK"]);
|
||||
expect(await zusammenfassungVersenden(spaeter(60))).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
|
||||
|
||||
const zeilen = await prisma.zustellung.findMany({ where: { anlass: `drift:${abweichung.id}` } });
|
||||
expect(zeilen.map((z) => z.ergebnis)).toEqual(["GESENDET", "GESENDET"]);
|
||||
const protokoll = await prisma.protokoll.findMany({ where: { aktion: "benachrichtigung.gesendet" } });
|
||||
expect(protokoll).toHaveLength(2);
|
||||
expect(JSON.stringify(protokoll, (_k, w: unknown) => (typeof w === "bigint" ? 0 : w))).not.toContain(
|
||||
"geheimer-pfad",
|
||||
);
|
||||
});
|
||||
|
||||
it("eine neue Abweichung kommt in die nächste Zusammenfassung, die alte nicht noch einmal", async () => {
|
||||
await hostMitDrift();
|
||||
await zusammenfassungVersenden(JETZT);
|
||||
versand.gesendet = [];
|
||||
const { host } = await hostMitDrift();
|
||||
await zusammenfassungVersenden(spaeter(24 * 60));
|
||||
const email = versand.gesendet.find((g) => g.kanal === "EMAIL")!.nachricht;
|
||||
expect(email.betreff).toContain("1 neue Abweichung");
|
||||
expect(email.text).toContain(host.name);
|
||||
expect(email.text).not.toContain("srv-1 ");
|
||||
});
|
||||
|
||||
it("wiederholt Fehlschläge begrenzt und nur nach der Wartezeit", async () => {
|
||||
await hostMitDrift();
|
||||
versand.fehler = "SMTP: 535 Authentication unsuccessful";
|
||||
expect(await zusammenfassungVersenden(JETZT)).toEqual({ gesendet: 0, fehlgeschlagen: 2 });
|
||||
const zeile = await prisma.zustellung.findFirstOrThrow({ where: { kanal: "EMAIL" } });
|
||||
expect(zeile).toMatchObject({
|
||||
ergebnis: "FEHLGESCHLAGEN",
|
||||
versuche: 1,
|
||||
fehler: "SMTP: 535 Authentication unsuccessful",
|
||||
});
|
||||
expect(zeile.naechsterVersuchAm).toEqual(spaeter(5));
|
||||
expect(await prisma.protokoll.count({ where: { aktion: "benachrichtigung.fehlgeschlagen" } })).toBe(2);
|
||||
|
||||
// Vor Ablauf der Wartezeit nichts; danach holt der Takt nach.
|
||||
expect(await taktVersenden(spaeter(2))).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
|
||||
versand.fehler = null;
|
||||
expect(await taktVersenden(spaeter(6))).toEqual({ gesendet: 2, fehlgeschlagen: 0 });
|
||||
expect(await prisma.zustellung.findFirstOrThrow({ where: { kanal: "EMAIL" } })).toMatchObject({
|
||||
ergebnis: "GESENDET",
|
||||
versuche: 2,
|
||||
naechsterVersuchAm: null,
|
||||
});
|
||||
|
||||
// Der Takt verschickt nichts Neues – das wartet auf die Zusammenfassung.
|
||||
await hostMitDrift();
|
||||
expect(await taktVersenden(spaeter(20))).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
|
||||
});
|
||||
|
||||
it("gibt nach fünf Versuchen auf", async () => {
|
||||
await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: false }, AKTEUR);
|
||||
await hostMitDrift();
|
||||
versand.fehler = "kaputt";
|
||||
await zusammenfassungVersenden(JETZT);
|
||||
for (const minuten of [10, 30, 60, 120, 240, 480]) await taktVersenden(spaeter(minuten));
|
||||
const zeile = await prisma.zustellung.findFirstOrThrow();
|
||||
expect(zeile).toMatchObject({ versuche: 5, naechsterVersuchAm: null, ergebnis: "FEHLGESCHLAGEN" });
|
||||
});
|
||||
|
||||
it("Ablauf: je Stufe genau eine Meldung", async () => {
|
||||
await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: false }, AKTEUR);
|
||||
const tag = 24 * 60;
|
||||
await schluessel(spaeter(20 * tag));
|
||||
await zusammenfassungVersenden(JETZT);
|
||||
await zusammenfassungVersenden(spaeter(1 * tag));
|
||||
await zusammenfassungVersenden(spaeter(7 * tag)); // noch 13 Tage → Stufe 14
|
||||
await zusammenfassungVersenden(spaeter(8 * tag));
|
||||
await zusammenfassungVersenden(spaeter(14 * tag)); // noch 6 Tage → Stufe 7
|
||||
await zusammenfassungVersenden(spaeter(19 * tag));
|
||||
const anlaesse = (await prisma.zustellung.findMany({ orderBy: { zeitpunkt: "asc" } })).map((z) =>
|
||||
z.anlass.split(":").slice(0, 3).join(":"),
|
||||
);
|
||||
expect(anlaesse).toEqual([
|
||||
expect.stringMatching(/^ablauf:.+:30$/),
|
||||
expect.stringMatching(/^ablauf:.+:14$/),
|
||||
expect.stringMatching(/^ablauf:.+:7$/),
|
||||
]);
|
||||
expect(versand.gesendet).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("abgelaufen und noch ausgerollt; unerreichbar einmal je Ausfall", async () => {
|
||||
await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: false }, AKTEUR);
|
||||
const host = await prisma.host.create({
|
||||
data: { name: "srv-weg", adresse: "10.9.9.9", bootstrapAm: JETZT, erreichbar: false },
|
||||
});
|
||||
const alt = await schluessel(spaeter(-60));
|
||||
await prisma.publicVerwendung.create({
|
||||
data: { schluesselId: alt.id, hostId: host.id, zielbenutzer: "root", zustand: "AUSGEROLLT" },
|
||||
});
|
||||
await zusammenfassungVersenden(JETZT);
|
||||
const text = versand.gesendet[0]!.nachricht.text;
|
||||
expect(text).toContain("Hosts nicht erreichbar:");
|
||||
expect(text).toContain("srv-weg (10.9.9.9) – noch nie erfolgreich abgeglichen");
|
||||
expect(text).toContain(`${alt.name} – abgelaufen am 12.10.2026, noch auf srv-weg`);
|
||||
|
||||
versand.gesendet = [];
|
||||
await zusammenfassungVersenden(spaeter(24 * 60));
|
||||
expect(versand.gesendet).toHaveLength(0);
|
||||
|
||||
// Erholt und wieder ausgefallen: neuer Ausfall, neue Meldung.
|
||||
await prisma.host.update({ where: { id: host.id }, data: { letzterAbgleichAm: spaeter(25 * 60) } });
|
||||
await zusammenfassungVersenden(spaeter(48 * 60));
|
||||
expect(versand.gesendet).toHaveLength(1);
|
||||
expect(versand.gesendet[0]!.nachricht.betreff).toContain("1 Host nicht erreichbar");
|
||||
});
|
||||
|
||||
it("ausgeschaltete Anlässe werden nicht gemeldet", async () => {
|
||||
await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: false, anlaesse: ["ablauf"] }, AKTEUR);
|
||||
await hostMitDrift();
|
||||
expect(await zusammenfassungVersenden(JETZT)).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
|
||||
});
|
||||
|
||||
it("geänderter Host-Key: sofort im Takt, nicht in der Zusammenfassung, nicht als unerreichbar", async () => {
|
||||
const host = await prisma.host.create({
|
||||
data: { name: "srv-mitm", adresse: "10.6.6.6", bootstrapAm: JETZT, erreichbar: false },
|
||||
});
|
||||
await prisma.abweichung.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
art: "HOSTKEY_GEAENDERT",
|
||||
fingerprint: "SHA256:neu",
|
||||
details: { erwartet: "SHA256:alt", algorithmus: "ssh-ed25519" },
|
||||
},
|
||||
});
|
||||
expect(await zusammenfassungVersenden(JETZT)).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
|
||||
expect(await taktVersenden(JETZT)).toEqual({ gesendet: 2, fehlgeschlagen: 0 });
|
||||
expect(versand.gesendet[0]!.nachricht.betreff).toBe(
|
||||
"[sshkv] DRINGEND: Host-Key von srv-mitm hat sich geändert",
|
||||
);
|
||||
expect(await hostKeyMeldungenVersenden(spaeter(5))).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
|
||||
});
|
||||
|
||||
it("Testnachrichten landen im Protokoll und werden nicht wiederholt", async () => {
|
||||
versand.fehler = "Webhook antwortet mit HTTP 500.";
|
||||
expect(await testVersenden("WEBHOOK", AKTEUR)).toBe("Webhook antwortet mit HTTP 500.");
|
||||
const zeile = await prisma.zustellung.findFirstOrThrow();
|
||||
expect(zeile.anlass).toMatch(/^test:/);
|
||||
expect(zeile.naechsterVersuchAm).toBeNull();
|
||||
const protokoll = await prisma.protokoll.findFirstOrThrow({
|
||||
where: { aktion: "benachrichtigung.fehlgeschlagen" },
|
||||
});
|
||||
expect(protokoll).toMatchObject({
|
||||
akteurName: "Ada Admin",
|
||||
details: { ziel: "https://hooks.example.de" },
|
||||
});
|
||||
const seite = await einstellungenSeite();
|
||||
expect(seite.zustellungen[0]).toMatchObject({
|
||||
kanal: "WEBHOOK",
|
||||
ergebnis: "FEHLGESCHLAGEN",
|
||||
anlaesse: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user