diff --git a/docs/anleitung/bilder/einstellungen-desktop.png b/docs/anleitung/bilder/einstellungen-desktop.png new file mode 100644 index 0000000..8d672e1 Binary files /dev/null and b/docs/anleitung/bilder/einstellungen-desktop.png differ diff --git a/docs/anleitung/bilder/einstellungen-mobil.png b/docs/anleitung/bilder/einstellungen-mobil.png new file mode 100644 index 0000000..20f693c Binary files /dev/null and b/docs/anleitung/bilder/einstellungen-mobil.png differ diff --git a/docs/issues/028-benachrichtigungen.md b/docs/issues/028-benachrichtigungen.md index e2aa695..2813732 100644 --- a/docs/issues/028-benachrichtigungen.md +++ b/docs/issues/028-benachrichtigungen.md @@ -1,19 +1,65 @@ # #028 Benachrichtigungen -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.5.0 **Labels:** backend -**Geschätzt:** 2 h **Tatsächlich:** – +**Geschätzt:** 2 h **Tatsächlich:** 0,5 h ## Ziel E-Mail (M365 SMTP) und Webhook bei Drift und Ablauf. ## Aufgaben -- [ ] wird zu Beginn der Phase verfeinert +- [x] Einstellungsseite `/einstellungen` statt Platzhalter: lesen alle Rollen, ändern nur + `einstellungenVerwalten` (Admin). Abschnitte „Benachrichtigungen“, „Abgleich“ (Zeitpläne aus + `SSHKV_SYNC_CRON`/`SSHKV_MELDUNG_CRON`, nur Anzeige) und „Instanz“ (Version, + Deployment-Key-Fingerprint, OIDC-Anbieter, TOTP des Notfall-Admins). Platz für „Syslog“ markiert. +- [x] Kanal E-Mail (SMTP): Vorbelegung Microsoft 365 (`smtp.office365.com:587`, STARTTLS), wahlweise + TLS (465) oder unverschlüsselt (nur internes Relay, mit Warnung); Absender, Benutzer, Passwort, + Empfängerliste. +- [x] Kanal Webhook: JSON per POST, optional `X-Sshkv-Signatur: sha256=` über den Body; nur + `https://`, `http://` nur per Häkchen mit Warnung; keine Weiterleitungen, Zeitlimit 15 s. +- [x] Geheimnisse (SMTP-Passwort, Webhook-Geheimnis) mit `verschluesseln()` in `Einstellung`; + die Oberfläche bekommt nur „gesetzt“, leeres Feld behält, Häkchen entfernt. +- [x] „Testnachricht senden“ je Kanal als Vorgang `BENACHRICHTIGUNG_TEST` im Worker. +- [x] Anlässe (je einzeln schaltbar): neue Abweichungen je Host, Ablauf in 30/14/7 Tagen, + abgelaufen aber ausgerollt, Host nicht erreichbar – gesammelt in der täglichen Zusammenfassung + (`SSHKV_MELDUNG_CRON`, Standard `0 7 * * *`); geänderter Host-Key sofort (Takt alle 5 Minuten). +- [x] Versandprotokoll `Zustellung` (Migration `20261012161000_benachrichtigungen`) mit + Anlass-Fingerabdruck, Kanal, Ergebnis, Fehler, Versuchen; Prüfung vor jedem Versand, + Wiederholung nach 5/10/20/40 Minuten (höchstens 5 Versuche); „Letzte Zustellungen“ auf der Seite. +- [x] Jede Zustellung in `protokollieren()` als `benachrichtigung.gesendet`/`…fehlgeschlagen` + (Empfänger nur gezählt, Webhook nur Schema und Host). +- [x] Zeitpläne in `src/worker/index.ts`; `.env.example` und `docker-compose.yml` ergänzt. +- [x] Tests: Unit (Nachrichtenaufbau, Signatur, Stufen, Deduplizierung, Prüfungen), DB + (Einstellungen, Versandprotokoll), Integration gegen Mailpit und lokalen HTTP-Empfänger, + E2E `tests/e2e/einstellungen.spec.ts`; Screenshots `einstellungen-desktop/-mobil`. ## Akzeptanzkriterien -- [ ] wird zu Beginn der Phase verfeinert +- [x] Admin speichert SMTP-Einstellungen, die Testnachricht kommt in Mailpit an (E2E). +- [x] Das Passwort steht nie im Klartext in Datenbank, Protokoll oder Seite; das Feld zeigt nur „gesetzt“. +- [x] Leser sieht die Seite ohne Ändern- und Test-Knöpfe. +- [x] Dieselbe Abweichung, dieselbe Ablaufstufe und derselbe Ausfall werden je Kanal genau einmal gemeldet. +- [x] Fehlgeschlagene Zustellungen werden begrenzt wiederholt und sind in der Oberfläche sichtbar. +- [x] Ein geänderter Host-Key wird innerhalb von fünf Minuten gemeldet, ohne den Abgleich-Ablauf zu ändern. ## Abhängigkeiten #021 ## Notizen / Entscheidungen +- **Versand im Worker**, auch für die Testnachricht: der Webprozess braucht so keinen Weg zu + Mailserver oder Webhook-Ziel, und regulärer Versand und Test laufen über denselben Code. Die + Oberfläche verfolgt den Test wie andere Vorgänge über `VorgangsKnopf`. +- **Anlässe aus der Datenbank**, nicht aus dem Abgleich: `src/worker/ablaeufe/abgleich.ts` bleibt + unverändert; der Sammler liest offene `Abweichung`, `Host.erreichbar = false` und Ablaufdaten. + Ein verpasster Lauf holt sich der nächste. +- **Fingerabdrücke:** `drift:`, `hostkey:`, + `ablauf:::` (mit Datum, damit ein verlängerter Schlüssel neu + erinnert wird), `abgelaufen::`, + `unerreichbar::` (ändert sich nur bei gelungenem Abgleich → eine Meldung + je Ausfall). Statt „seit der letzten Meldung“ zählt das Versandprotokoll – wer Benachrichtigungen + erstmals einschaltet, bekommt die offenen Abweichungen einmal gemeldet. +- Hosts mit offenem `HOSTKEY_GEAENDERT` erscheinen nicht zusätzlich als „nicht erreichbar“. +- Der Fünf-Minuten-Takt verschickt dringende Host-Key-Meldungen und fällige Wiederholungen; + Neues aus (a)–(d) wartet auf die Zusammenfassung. Läufe im selben Worker laufen nacheinander. +- Leser sehen statt der Webhook-Adresse nur Schema und Host (Tokens im Pfad). +- Mailpit für Tests mit `MP_SMTP_DISABLE_RDNS=true` starten, sonst wartet es je Verbindung 5 s. +- `nodemailer@10.0.10` wie in ProxLedger, exakt gepinnt. diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index d691d4f..a7a59ef 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -49,6 +49,8 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri pfad: "/vorlagen", folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]', }, + // Benachrichtigungen, Zeitplaene, Instanz (#028) – als Admin mit Formular und Testknoepfen. + { name: "einstellungen", pfad: "/einstellungen" }, ]; const ANSICHTEN = [ diff --git a/tests/e2e/einstellungen.spec.ts b/tests/e2e/einstellungen.spec.ts new file mode 100644 index 0000000..8c6187f --- /dev/null +++ b/tests/e2e/einstellungen.spec.ts @@ -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(); +});