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((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 { // 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 fett" }, }, }); 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 <b>fett</b>"); // 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; 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); }); });