Files
sshkeyvault/tests/benachrichtigung.test.ts
T
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

355 lines
15 KiB
TypeScript
Raw 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 { 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,
});
});
});