test(einstellungen): E2E, Screenshots und Issue-Abschluss (#028)

Der E2E-Lauf belegt im gebauten Stand, was die Unit- und DB-Tests nur
einzeln zeigen: ein Admin speichert SMTP, die Testnachricht kommt ueber
den Worker in Mailpit an, das Passwort kehrt nie in die Seite zurueck,
und ein Leser sieht keine Aendern-Knoepfe. Die Screenshots (1440/390 px)
halten das Layout der neuen Seite fuer die Anleitung fest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:54:56 +02:00
co-authored by Claude Opus 5.5
parent 8f5b31c0cb
commit 3e19b11a80
5 changed files with 154 additions and 4 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

+50 -4
View File
@@ -1,19 +1,65 @@
# #028 Benachrichtigungen # #028 Benachrichtigungen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.5.0 **Meilenstein:** v0.5.0
**Labels:** backend **Labels:** backend
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** 0,5 h
## Ziel ## Ziel
E-Mail (M365 SMTP) und Webhook bei Drift und Ablauf. E-Mail (M365 SMTP) und Webhook bei Drift und Ablauf.
## Aufgaben ## 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=<HMAC>` ü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 ## 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 ## Abhängigkeiten
#021 #021
## Notizen / Entscheidungen ## 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:<abweichungId>`, `hostkey:<abweichungId>`,
`ablauf:<schluesselId>:<stufe>:<ablaufdatum>` (mit Datum, damit ein verlängerter Schlüssel neu
erinnert wird), `abgelaufen:<schluesselId>:<ablaufdatum>`,
`unerreichbar:<hostId>:<letzterAbgleichAm>` (ä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.
+2
View File
@@ -49,6 +49,8 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
pfad: "/vorlagen", pfad: "/vorlagen",
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]', folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
}, },
// Benachrichtigungen, Zeitplaene, Instanz (#028) – als Admin mit Formular und Testknoepfen.
{ name: "einstellungen", pfad: "/einstellungen" },
]; ];
const ANSICHTEN = [ const ANSICHTEN = [
+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();
});