Merge branch 'phase/028-benachrichtigungen'

# Conflicts:
#	docker-compose.yml
#	scripts/screenshots.ts
This commit is contained in:
2026-10-12 16:55:57 +02:00
31 changed files with 3397 additions and 16 deletions
+197
View File
@@ -0,0 +1,197 @@
import { createHmac } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
hostKeyNachricht,
signatur,
testNachricht,
webhookKoerper,
zusammenfassungLeer,
zusammenfassungNachricht,
} from "@/lib/benachrichtigung/nachrichten";
import { hostKeyGruppen, zusammenfassungAus, type Posten } from "@/lib/benachrichtigung/posten";
/** Nachrichtenaufbau (#028): Text- und HTML-Mail auf Deutsch, Webhook-JSON, Signatur. */
const RAHMEN = { appUrl: "https://sshkv.example.de/", jetzt: new Date("2026-10-12T05:00:00Z") };
const POSTEN: Posten[] = [
{
art: "drift",
anlass: "drift:a1",
host: { hostId: "h2", name: "srv-zeta", adresse: "10.0.0.2" },
befund: { art: "RECHTE", zielbenutzer: "root", fingerprint: null, text: "authorized_keys ist 0666" },
},
{
art: "drift",
anlass: "drift:a2",
host: { hostId: "h1", name: "srv-alpha", adresse: "10.0.0.1" },
befund: {
art: "UNBEKANNTER_SCHLUESSEL",
zielbenutzer: "deploy",
fingerprint: "SHA256:abc",
text: "Kommentar <script>alert(1)</script>",
},
},
{
art: "drift",
anlass: "drift:a3",
host: { hostId: "h1", name: "srv-alpha", adresse: "10.0.0.1" },
befund: { art: "FEHLENDER_SCHLUESSEL", zielbenutzer: "deploy", fingerprint: "SHA256:def", text: null },
},
{
art: "unerreichbar",
anlass: "unerreichbar:h3:nie",
host: {
hostId: "h3",
name: "srv-weg",
adresse: "10.0.0.3",
zuletztGeprueftAm: null,
letzterAbgleichAm: null,
},
},
{
art: "ablauf",
anlass: "ablauf:k1:7:2026-10-17",
schluessel: {
schluesselId: "k1",
name: "Backup-Zugang",
fingerprint: "SHA256:k1",
ablaufAm: new Date("2026-10-17T10:00:00Z"),
tage: 5,
stufe: 7,
},
},
{
art: "abgelaufen",
anlass: "abgelaufen:k2:2026-10-01",
schluessel: {
schluesselId: "k2",
name: "Alter Zugang",
fingerprint: "SHA256:k2",
ablaufAm: new Date("2026-10-01T10:00:00Z"),
hosts: ["srv-alpha", "Gruppe pve"],
},
},
];
describe("Zusammenfassung", () => {
const z = zusammenfassungAus(POSTEN);
const n = zusammenfassungNachricht(z, RAHMEN);
it("fasst Drift je Host zusammen und sortiert nach Namen", () => {
expect(z.drift.map((h) => [h.name, h.befunde.length])).toEqual([
["srv-alpha", 2],
["srv-zeta", 1],
]);
expect(zusammenfassungLeer(z)).toBe(false);
expect(zusammenfassungLeer(zusammenfassungAus([]))).toBe(true);
});
it("Betreff zählt die Anlässe auf Deutsch", () => {
expect(n.betreff).toBe(
"[sshkv] Zusammenfassung: 3 neue Abweichungen, 1 Host nicht erreichbar, 1 Schlüssel läuft ab, 1 abgelaufener Schlüssel ausgerollt",
);
});
it("Textfassung mit Umlauten, Links und allen Abschnitten", () => {
expect(n.text).toContain("Zusammenfassung vom 12.10.2026, 07:00");
expect(n.text).toContain("Neue Abweichungen (Drift):");
expect(n.text).toContain(" srv-alpha (10.0.0.1):");
expect(n.text).toContain("Unbekannter Schlüssel – Benutzer deploy – SHA256:abc");
expect(n.text).toContain("https://sshkv.example.de/hosts/h1");
expect(n.text).toContain("srv-weg (10.0.0.3) – noch nie erfolgreich abgeglichen");
expect(n.text).toContain("Backup-Zugang – läuft am 17.10.2026 ab (in 5 Tagen)");
expect(n.text).toContain("Alter Zugang – abgelaufen am 01.10.2026, noch auf srv-alpha, Gruppe pve");
});
it("HTML maskiert Inhalte von den Hosts", () => {
expect(n.html).toContain('<html lang="de">');
expect(n.html).not.toContain("<script>");
expect(n.html).toContain("Kommentar &lt;script&gt;alert(1)&lt;/script&gt;");
expect(n.html).toContain("Bald ablaufende Schlüssel");
});
it("Webhook-Nutzlast mit Ereignisart, Host, Befunden und Zeitstempel", () => {
const json = JSON.parse(webhookKoerper(n)) as Record<string, unknown>;
expect(json).toMatchObject({
ereignis: "zusammenfassung",
zeitstempel: "2026-10-12T05:00:00.000Z",
instanz: "https://sshkv.example.de",
betreff: n.betreff,
});
expect((json["drift"] as unknown[])[0]).toEqual({
host: { id: "h1", name: "srv-alpha", adresse: "10.0.0.1" },
befunde: [
{
art: "UNBEKANNTER_SCHLUESSEL",
artText: "Unbekannter Schlüssel",
zielbenutzer: "deploy",
fingerprint: "SHA256:abc",
text: "Kommentar <script>alert(1)</script>",
},
{
art: "FEHLENDER_SCHLUESSEL",
artText: "Schlüssel fehlt",
zielbenutzer: "deploy",
fingerprint: "SHA256:def",
text: null,
},
],
});
expect(json["ablauf"]).toEqual([
{
schluessel: { id: "k1", name: "Backup-Zugang", fingerprint: "SHA256:k1" },
ablaufAm: "2026-10-17T10:00:00.000Z",
tage: 5,
stufe: 7,
},
]);
});
});
describe("Host-Key geändert (dringend)", () => {
const gruppen = hostKeyGruppen([
{
art: "hostkey",
anlass: "hostkey:x1",
host: { hostId: "h1", name: "srv-alpha", adresse: "10.0.0.1" },
befund: { algorithmus: "ssh-ed25519", erwartet: "SHA256:alt", gesehen: "SHA256:neu" },
},
...POSTEN,
]);
it("je Host eine Nachricht, nur aus Host-Key-Posten", () => {
expect(gruppen).toHaveLength(1);
expect(gruppen[0]?.anlaesse).toEqual(["hostkey:x1"]);
});
it("Betreff, Text und Nutzlast", () => {
const n = hostKeyNachricht(gruppen[0]!.aenderung, RAHMEN);
expect(n.betreff).toBe("[sshkv] DRINGEND: Host-Key von srv-alpha hat sich geändert");
expect(n.text).toContain("ssh-ed25519: erwartet SHA256:alt, gesehen SHA256:neu");
expect(n.text).toContain("bestätigt");
expect(n.html).toContain("Dringend");
expect(n.nutzlast).toMatchObject({
ereignis: "hostkey.geaendert",
dringend: true,
host: { id: "h1", name: "srv-alpha" },
befunde: [{ art: "HOSTKEY_GEAENDERT", erwartet: "SHA256:alt", fingerprint: "SHA256:neu" }],
});
});
});
describe("Testnachricht und Signatur", () => {
it("Testnachricht nennt den Kanal", () => {
const n = testNachricht("EMAIL", RAHMEN);
expect(n.betreff).toBe("[sshkv] Testnachricht");
expect(n.text).toContain("per E-Mail funktioniert");
expect(n.nutzlast.ereignis).toBe("test");
});
it("HMAC-SHA256 über den rohen Body", () => {
const koerper = webhookKoerper(testNachricht("WEBHOOK", RAHMEN));
const erwartet = createHmac("sha256", "geheim").update(koerper).digest("hex");
expect(signatur(koerper, "geheim")).toBe(`sha256=${erwartet}`);
expect(signatur(`${koerper} `, "geheim")).not.toBe(`sha256=${erwartet}`);
});
});
+194
View File
@@ -0,0 +1,194 @@
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/);
});
});
+354
View File
@@ -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,
});
});
});
+102
View File
@@ -0,0 +1,102 @@
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Einstellungen und Benachrichtigungen im gebauten Stand (#028). Braucht neben Web und Worker
* ein Mailpit (siehe tests/integration/benachrichtigung.test.ts) unter SSHKV_TEST_SMTP und
* SSHKV_TEST_MAILPIT_API.
*/
const SMTP = process.env["SSHKV_TEST_SMTP"]?.trim() || "127.0.0.1:1026";
const MAILPIT = (process.env["SSHKV_TEST_MAILPIT_API"]?.trim() || "http://127.0.0.1:8026").replace(
/\/+$/,
"",
);
const [SMTP_HOST, SMTP_PORT] = [SMTP.slice(0, SMTP.lastIndexOf(":")), SMTP.slice(SMTP.lastIndexOf(":") + 1)];
const PASSWORT = "e2e-smtp-passwort-nie-sichtbar";
async function oidcAnmelden(page: Page, rolle: "admin" | "reader") {
await page.goto("/anmelden");
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
await page.locator(`button[data-rolle="${rolle}"]`).click();
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
}
function emailKarte(page: Page) {
// Die innerste Section mit der Ueberschrift – die aeussere ist der Abschnitt „Benachrichtigungen“.
return page
.locator("section")
.filter({ has: page.getByRole("heading", { name: "E-Mail (SMTP)" }) })
.last();
}
async function mailpitSuchen(empfaenger: string): Promise<{ Subject: string }[]> {
for (let versuch = 0; versuch < 40; versuch += 1) {
const antwort = await fetch(`${MAILPIT}/api/v1/search?query=${encodeURIComponent(`to:${empfaenger}`)}`);
const daten = (await antwort.json()) as { messages: { Subject: string }[] };
if (daten.messages.length > 0) return daten.messages;
await new Promise((weiter) => setTimeout(weiter, 250));
}
return [];
}
test("Admin speichert SMTP, sendet eine Testnachricht, sie kommt in Mailpit an", async ({ page }) => {
const empfaenger = `e2e-${Date.now().toString(36)}@sshkv.test`;
await oidcAnmelden(page, "admin");
await page.goto("/einstellungen");
await expect(page.getByRole("heading", { name: "Einstellungen", level: 1 })).toBeVisible();
const karte = emailKarte(page);
await karte.getByRole("checkbox", { name: "Per E-Mail benachrichtigen" }).check();
await karte.getByLabel("Server").fill(SMTP_HOST);
await karte.getByRole("textbox", { name: "Port" }).fill(SMTP_PORT);
await karte.getByRole("radio", { name: /Unverschlüsselt/ }).check();
await expect(karte.getByRole("note")).toContainText("Klartext");
await karte.getByLabel("Absender").fill("sshkv@sshkv.test");
await karte.getByLabel("Benutzer (optional)").fill("");
await karte.getByLabel("Passwort", { exact: true }).fill(PASSWORT);
await karte.getByLabel("Empfänger").fill(empfaenger);
await page.getByRole("button", { name: "Benachrichtigungen speichern" }).click();
await expect(page.getByText("Einstellungen gespeichert.")).toBeVisible();
// Das Passwort kommt nie zurueck: Feld leer, nur „gesetzt“.
const passwort = emailKarte(page).getByLabel("Passwort", { exact: true });
await expect(passwort).toHaveValue("");
await expect(passwort).toHaveAttribute("placeholder", /gesetzt/);
await expect(emailKarte(page).getByText("gesetzt", { exact: true })).toBeVisible();
await page.reload();
expect(await page.content()).not.toContain(PASSWORT);
await expect(emailKarte(page).getByLabel("Server")).toHaveValue(SMTP_HOST);
await emailKarte(page).getByRole("button", { name: "Testnachricht senden" }).click();
await expect(emailKarte(page).getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
const mails = await mailpitSuchen(empfaenger);
expect(mails.map((m) => m.Subject)).toEqual(["[sshkv] Testnachricht"]);
const zustellungen = page
.locator("section")
.filter({ has: page.getByRole("heading", { name: "Letzte Zustellungen" }) })
.last();
await expect(zustellungen.getByRole("row").nth(1)).toContainText("[sshkv] Testnachricht");
await expect(zustellungen.getByRole("row").nth(1)).toContainText("gesendet");
// Webhook nur mit https – http ohne Haekchen ergibt eine Feldmeldung.
await page.getByRole("textbox", { name: "Adresse", exact: true }).fill("http://10.0.0.9/hook");
await page.getByRole("button", { name: "Benachrichtigungen speichern" }).click();
await expect(page.locator("#feld-webhookUrl-fehler")).toContainText("Nur https://");
});
test("Leser sieht die Einstellungen ohne Ändern-Knöpfe", async ({ page }) => {
await oidcAnmelden(page, "reader");
await page.goto("/einstellungen");
await expect(page.getByRole("heading", { name: "Einstellungen", level: 1 })).toBeVisible();
await expect(page.getByText("Nur Ansicht – ändern dürfen Administratoren.")).toBeVisible();
await expect(page.getByRole("button", { name: "Benachrichtigungen speichern" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Testnachricht senden" })).toHaveCount(0);
await expect(emailKarte(page).getByLabel("Server")).toBeDisabled();
// Statt eines Passwortfelds nur der Hinweis, ob eins gesetzt ist.
await expect(page.locator('input[type="password"]')).toHaveCount(0);
await expect(emailKarte(page).getByText("gesetzt", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Instanz" })).toBeVisible();
await expect(page.getByRole("button", { name: /Deployment-Keys kopieren/ })).toBeVisible();
});
+200
View File
@@ -0,0 +1,200 @@
import { randomUUID } from "node:crypto";
import { createServer, type IncomingHttpHeaders, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { signatur } from "@/lib/benachrichtigung/nachrichten";
import { prisma } from "@/lib/db";
import { benachrichtigungSpeichern } from "@/server/services/benachrichtigung";
import { taktVersenden, testVersenden, zusammenfassungVersenden } from "@/worker/benachrichtigung/versand";
import { datenbankLeeren } from "../hilfen/db";
/**
* Benachrichtigungen gegen echte Gegenstellen (#028): Mailpit fuer SMTP und ein lokaler
* HTTP-Empfaenger fuer den Webhook. Mailpit muss laufen, z. B.
* docker run -d --name sshkv-mailpit -e MP_SMTP_DISABLE_RDNS=true \
* -p 127.0.0.1:1026:1025 -p 127.0.0.1:8026:8025 axllent/mailpit
* (ohne MP_SMTP_DISABLE_RDNS wartet Mailpit je Verbindung fuenf Sekunden auf Reverse-DNS).
* Adressen ueber SSHKV_TEST_SMTP (host:port) und SSHKV_TEST_MAILPIT_API.
*/
const SMTP = process.env["SSHKV_TEST_SMTP"]?.trim() || "127.0.0.1:1026";
const MAILPIT = (process.env["SSHKV_TEST_MAILPIT_API"]?.trim() || "http://127.0.0.1:8026").replace(
/\/+$/,
"",
);
const [SMTP_HOST, SMTP_PORT] = [SMTP.slice(0, SMTP.lastIndexOf(":")), SMTP.slice(SMTP.lastIndexOf(":") + 1)];
const AKTEUR = { id: "admin", name: "Ada Admin" };
const GEHEIMNIS = "integration-webhook-geheimnis";
type Empfangen = { kopf: IncomingHttpHeaders; koerper: string };
let empfaenger: Server;
let empfangen: Empfangen[] = [];
let antwortStatus = 204;
let webhookUrl = "";
beforeAll(async () => {
const antwort = await fetch(`${MAILPIT}/api/v1/info`).catch(() => null);
if (!antwort?.ok) throw new Error(`Mailpit unter ${MAILPIT} nicht erreichbar – siehe Kopf dieser Datei.`);
empfaenger = createServer((anfrage, antwort) => {
const teile: Buffer[] = [];
anfrage.on("data", (teil: Buffer) => teile.push(teil));
anfrage.on("end", () => {
empfangen.push({ kopf: anfrage.headers, koerper: Buffer.concat(teile).toString("utf8") });
antwort.writeHead(antwortStatus).end();
});
});
await new Promise<void>((fertig) => empfaenger.listen(0, "127.0.0.1", fertig));
webhookUrl = `http://127.0.0.1:${(empfaenger.address() as AddressInfo).port}/hook/sshkv`;
});
afterAll(async () => {
await new Promise((fertig) => empfaenger.close(fertig));
});
let postfach = "";
beforeEach(async () => {
await datenbankLeeren();
empfangen = [];
antwortStatus = 204;
// Eigene Adresse je Test: Mailpit wird nicht geleert, gesucht wird nach dem Empfaenger.
postfach = `lauf-${randomUUID().slice(0, 8)}@sshkv.test`;
});
async function einstellen(sicherheit: "KEINE" | "STARTTLS" = "KEINE") {
await benachrichtigungSpeichern(
{
emailAktiv: true,
emailServer: SMTP_HOST,
emailPort: SMTP_PORT,
emailSicherheit: sicherheit,
emailAbsender: "sshkv@sshkv.test",
emailBenutzer: "",
emailPasswortEntfernen: false,
emailEmpfaenger: [postfach],
webhookAktiv: true,
webhookUrl,
webhookHttpErlaubt: true,
webhookGeheimnis: GEHEIMNIS,
webhookGeheimnisEntfernen: false,
anlaesse: ["drift", "ablauf", "abgelaufen", "unerreichbar", "hostkey"],
},
AKTEUR,
);
}
type MailpitTreffer = { ID: string; Subject: string; To: { Address: string }[] };
async function postfachLesen(): Promise<MailpitTreffer[]> {
// Mailpit nimmt die Nachricht asynchron an – kurz warten, bis sie suchbar ist.
for (let versuch = 0; versuch < 20; versuch += 1) {
const antwort = await fetch(`${MAILPIT}/api/v1/search?query=${encodeURIComponent(`to:${postfach}`)}`);
const daten = (await antwort.json()) as { messages: MailpitTreffer[] };
if (daten.messages.length > 0) return daten.messages;
await new Promise((weiter) => setTimeout(weiter, 150));
}
return [];
}
async function mailLesen(id: string): Promise<{ Text: string; HTML: string; Subject: string }> {
const antwort = await fetch(`${MAILPIT}/api/v1/message/${id}`);
return (await antwort.json()) as { Text: string; HTML: string; Subject: string };
}
describe("E-Mail über Mailpit", () => {
it("Testnachricht kommt an", async () => {
await einstellen();
expect(await testVersenden("EMAIL", AKTEUR)).toBeNull();
const treffer = await postfachLesen();
expect(treffer).toHaveLength(1);
expect(treffer[0]?.Subject).toBe("[sshkv] Testnachricht");
const mail = await mailLesen(treffer[0]!.ID);
expect(mail.Text).toContain("Versand per E-Mail funktioniert");
expect(mail.HTML).toContain("Testnachricht");
});
it("Zusammenfassung mit Drift je Host, Text und HTML", async () => {
await einstellen();
const host = await prisma.host.create({
data: { name: "srv-mail", adresse: "10.1.1.1", bootstrapAm: new Date(), erreichbar: true },
});
await prisma.abweichung.create({
data: {
hostId: host.id,
art: "UNBEKANNTER_SCHLUESSEL",
zielbenutzer: "root",
fingerprint: "SHA256:fremd",
details: { text: "Kommentar <b>fett</b>" },
},
});
expect(await zusammenfassungVersenden()).toEqual({ gesendet: 2, fehlgeschlagen: 0 });
const treffer = await postfachLesen();
expect(treffer[0]?.Subject).toBe("[sshkv] Zusammenfassung: 1 neue Abweichung");
const mail = await mailLesen(treffer[0]!.ID);
expect(mail.Text).toContain("srv-mail (10.1.1.1):");
expect(mail.Text).toContain("Unbekannter Schlüssel – Benutzer root – SHA256:fremd");
expect(mail.HTML).toContain("Kommentar &lt;b&gt;fett&lt;/b&gt;");
// Zweiter Lauf: nichts Neues, keine zweite Mail.
expect(await zusammenfassungVersenden()).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
expect(await postfachLesen()).toHaveLength(1);
});
it("STARTTLS ist Pflicht, wenn eingestellt – ohne TLS kein Versand", async () => {
await einstellen("STARTTLS");
const fehler = await testVersenden("EMAIL", AKTEUR);
expect(fehler).toMatch(/^SMTP: /);
const zeile = await prisma.zustellung.findFirstOrThrow({ where: { kanal: "EMAIL" } });
expect(zeile.ergebnis).toBe("FEHLGESCHLAGEN");
});
});
describe("Webhook an lokalen Empfänger", () => {
it("signiert den Body mit HMAC-SHA256", async () => {
await einstellen();
expect(await testVersenden("WEBHOOK", AKTEUR)).toBeNull();
expect(empfangen).toHaveLength(1);
const { kopf, koerper } = empfangen[0]!;
expect(kopf["content-type"]).toMatch(/^application\/json/);
expect(kopf["x-sshkv-ereignis"]).toBe("test");
expect(kopf["x-sshkv-signatur"]).toBe(signatur(koerper, GEHEIMNIS));
expect(JSON.parse(koerper)).toMatchObject({ ereignis: "test", betreff: "[sshkv] Testnachricht" });
});
it("geänderter Host-Key geht sofort raus; HTTP 500 wird später wiederholt", async () => {
await einstellen();
const host = await prisma.host.create({
data: { name: "srv-hook", adresse: "10.2.2.2", bootstrapAm: new Date() },
});
await prisma.abweichung.create({
data: {
hostId: host.id,
art: "HOSTKEY_GEAENDERT",
fingerprint: "SHA256:neu",
details: { erwartet: "SHA256:alt", algorithmus: "ssh-ed25519" },
},
});
antwortStatus = 500;
const jetzt = new Date();
expect(await taktVersenden(jetzt)).toEqual({ gesendet: 1, fehlgeschlagen: 1 });
const zeile = await prisma.zustellung.findFirstOrThrow({ where: { kanal: "WEBHOOK" } });
expect(zeile).toMatchObject({ ergebnis: "FEHLGESCHLAGEN", fehler: "Webhook antwortet mit HTTP 500." });
antwortStatus = 204;
empfangen = [];
expect(await taktVersenden(new Date(jetzt.getTime() + 6 * 60_000))).toEqual({
gesendet: 1,
fehlgeschlagen: 0,
});
const json = JSON.parse(empfangen[0]!.koerper) as Record<string, unknown>;
expect(json).toMatchObject({
ereignis: "hostkey.geaendert",
dringend: true,
host: { name: "srv-hook", adresse: "10.2.2.2" },
befunde: [{ fingerprint: "SHA256:neu", erwartet: "SHA256:alt" }],
});
expect(typeof json["zeitstempel"]).toBe("string");
expect(empfangen[0]!.kopf["x-sshkv-signatur"]).toBe(signatur(empfangen[0]!.koerper, GEHEIMNIS));
// Die E-Mail ging beim ersten Mal durch und kommt nicht noch einmal.
expect(await postfachLesen()).toHaveLength(1);
});
});