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