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

198 lines
6.6 KiB
TypeScript
Raw Permalink 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 { 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}`);
});
});