Files
sshkeyvault/tests/integration/benachrichtigung.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

201 lines
8.2 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 { 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);
});
});