syncova-backup/packages/alerting/delivery_test.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

384 lines
12 KiB
Go

package alerting
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
)
// buildTestAlert baut eine Meldung fuer die Zustellung.
func buildTestAlert() Alert {
return Alert{
ID: uuid.New(),
RuleName: RuleBackupFailure,
Severity: SeverityCritical,
Status: StatusOpen,
Title: "Sicherung gescheitert: Dateiserver",
Message: "Der letzte Lauf ist gescheitert. Pruefen Sie die Ursache.",
EntityType: "backup_job",
EntityName: "Dateiserver",
OccurrenceCount: 1,
FirstSeenAt: time.Now().UTC(),
}
}
// fakeSMTPServer nimmt eine Nachricht entgegen und gibt sie zurueck.
//
// Ein echter Server statt eines Attrappen-Interfaces: Nur so ist geprueft, dass
// die Nachricht tatsaechlich das SMTP-Gespraech uebersteht — Zeilenenden,
// Punktbehandlung und Kopfzeilen eingeschlossen. Ein Zusteller, der nur gegen
// eine Attrappe geprueft ist, scheitert beim ersten echten Server.
type fakeSMTPServer struct {
// listener nimmt Verbindungen an.
listener net.Listener
// mutex schuetzt die empfangenen Nachrichten.
mutex sync.Mutex
// receivedMessages haelt die empfangenen Nachrichten.
receivedMessages []string
// receivedRecipients haelt die genannten Empfaenger.
receivedRecipients []string
}
// startFakeSMTPServer startet einen SMTP-Empfaenger auf einem freien Port.
func startFakeSMTPServer(testInstance *testing.T) *fakeSMTPServer {
testInstance.Helper()
networkListener, listenError := net.Listen("tcp", "127.0.0.1:0")
if listenError != nil {
testInstance.Fatalf("der Testserver liess sich nicht starten: %v", listenError)
}
server := &fakeSMTPServer{listener: networkListener}
go server.acceptConnections()
testInstance.Cleanup(func() { _ = networkListener.Close() })
return server
}
// address liefert Host und Port des Servers.
func (server *fakeSMTPServer) address() (string, int) {
tcpAddress := server.listener.Addr().(*net.TCPAddr)
return "127.0.0.1", tcpAddress.Port
}
// messages liefert die empfangenen Nachrichten.
func (server *fakeSMTPServer) messages() []string {
server.mutex.Lock()
defer server.mutex.Unlock()
return append([]string(nil), server.receivedMessages...)
}
// recipients liefert die genannten Empfaenger.
func (server *fakeSMTPServer) recipients() []string {
server.mutex.Lock()
defer server.mutex.Unlock()
return append([]string(nil), server.receivedRecipients...)
}
// acceptConnections bedient eingehende Verbindungen.
func (server *fakeSMTPServer) acceptConnections() {
for {
clientConnection, acceptError := server.listener.Accept()
if acceptError != nil {
return
}
go server.handleConnection(clientConnection)
}
}
// handleConnection fuehrt ein einzelnes SMTP-Gespraech.
func (server *fakeSMTPServer) handleConnection(clientConnection net.Conn) {
defer func() { _ = clientConnection.Close() }()
connectionReader := bufio.NewReader(clientConnection)
writeLine := func(responseLine string) {
_, _ = fmt.Fprintf(clientConnection, "%s\r\n", responseLine)
}
writeLine("220 syncova-test ESMTP")
var messageBuilder strings.Builder
isReadingData := false
for {
receivedLine, readError := connectionReader.ReadString('\n')
if readError != nil {
return
}
trimmedLine := strings.TrimRight(receivedLine, "\r\n")
if isReadingData {
if trimmedLine == "." {
isReadingData = false
server.mutex.Lock()
server.receivedMessages = append(server.receivedMessages, messageBuilder.String())
server.mutex.Unlock()
messageBuilder.Reset()
writeLine("250 OK")
continue
}
messageBuilder.WriteString(trimmedLine)
messageBuilder.WriteString("\n")
continue
}
upperLine := strings.ToUpper(trimmedLine)
switch {
case strings.HasPrefix(upperLine, "EHLO"), strings.HasPrefix(upperLine, "HELO"):
// Ohne AUTH und ohne STARTTLS: Der Test prueft die Zustellung, nicht
// die Verschluesselung. net/smtp verzichtet dann von selbst auf beides.
writeLine("250-syncova-test")
writeLine("250 SIZE 10240000")
case strings.HasPrefix(upperLine, "MAIL FROM"):
writeLine("250 OK")
case strings.HasPrefix(upperLine, "RCPT TO"):
server.mutex.Lock()
server.receivedRecipients = append(server.receivedRecipients, trimmedLine)
server.mutex.Unlock()
writeLine("250 OK")
case upperLine == "DATA":
isReadingData = true
writeLine("354 Ende mit <CRLF>.<CRLF>")
case upperLine == "QUIT":
writeLine("221 Tschuess")
return
default:
writeLine("250 OK")
}
}
}
// TestEmailDeliveryReachesServer prueft die Zustellung per SMTP.
func TestEmailDeliveryReachesServer(testInstance *testing.T) {
smtpServer := startFakeSMTPServer(testInstance)
serverHost, serverPort := smtpServer.address()
emailChannel := Channel{
Name: "Betrieb",
ChannelType: ChannelEmail,
Enabled: true,
MinimumSeverity: SeverityHigh,
Configuration: map[string]any{
"host": serverHost,
"port": float64(serverPort),
"from": "syncova@example.test",
"to": []any{"betrieb@example.test", "bereitschaft@example.test"},
},
}
testAlert := buildTestAlert()
deliverer := &EmailDeliverer{}
if deliveryError := deliverer.Deliver(context.Background(), emailChannel, "", testAlert); deliveryError != nil {
testInstance.Fatalf("die Zustellung schlug fehl: %v", deliveryError)
}
receivedMessages := smtpServer.messages()
if len(receivedMessages) != 1 {
testInstance.Fatalf("der Server empfing %d Nachrichten, erwartet wurde genau eine",
len(receivedMessages))
}
receivedMessage := receivedMessages[0]
// Der Schweregrad gehoert in die Betreffzeile: Wer nachts auf sein Telefon
// sieht, liest den Betreff und sonst nichts.
if !strings.Contains(receivedMessage, "Subject: [Syncova CRITICAL]") {
testInstance.Errorf("der Betreff nennt den Schweregrad nicht:\n%s", receivedMessage)
}
if !strings.Contains(receivedMessage, testAlert.Title) {
testInstance.Error("die Nachricht enthaelt die Ueberschrift nicht")
}
// Die Meldungskennung erlaubt es, den Vorgang in der Oberflaeche
// wiederzufinden, ohne zu suchen.
if !strings.Contains(receivedMessage, testAlert.ID.String()) {
testInstance.Error("die Nachricht enthaelt die Meldungskennung nicht")
}
if len(smtpServer.recipients()) != 2 {
testInstance.Errorf("es wurden %d Empfaenger genannt, erwartet wurden 2",
len(smtpServer.recipients()))
}
}
// TestEmailDeliveryRejectsIncompleteConfiguration prueft die Ablehnung unvollstaendiger Kanaele.
func TestEmailDeliveryRejectsIncompleteConfiguration(testInstance *testing.T) {
deliverer := &EmailDeliverer{}
testAlert := buildTestAlert()
testCases := []struct {
name string
configuration map[string]any
}{
{"ohne Server", map[string]any{"from": "a@b.test", "to": "c@d.test"}},
{"ohne Absender", map[string]any{"host": "127.0.0.1", "to": "c@d.test"}},
{"ohne Empfaenger", map[string]any{"host": "127.0.0.1", "from": "a@b.test"}},
}
for _, testCase := range testCases {
channel := Channel{ChannelType: ChannelEmail, Configuration: testCase.configuration}
if deliveryError := deliverer.Deliver(context.Background(), channel, "",
testAlert); deliveryError == nil {
testInstance.Errorf("%s wurde angenommen", testCase.name)
}
}
}
// TestWebhookDeliveryReachesReceiver prueft die Zustellung per HTTP.
func TestWebhookDeliveryReachesReceiver(testInstance *testing.T) {
var (
receivedPayload webhookPayload
receivedHeader string
requestCount int
)
testServer := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
requestCount++
receivedHeader = request.Header.Get("Authorization")
_ = json.NewDecoder(request.Body).Decode(&receivedPayload)
responseWriter.WriteHeader(http.StatusAccepted)
}))
defer testServer.Close()
webhookChannel := Channel{
Name: "Leitstand",
ChannelType: ChannelWebhook,
Enabled: true,
Configuration: map[string]any{
"url": testServer.URL,
// Der Testserver spricht HTTP; im Betrieb verlangt der Zusteller
// HTTPS, sofern das nicht ausdruecklich abgewaehlt wird.
"allow_insecure": true,
},
}
testAlert := buildTestAlert()
deliverer := &WebhookDeliverer{httpClient: testServer.Client()}
if deliveryError := deliverer.Deliver(context.Background(), webhookChannel,
"geheim-123", testAlert); deliveryError != nil {
testInstance.Fatalf("die Zustellung schlug fehl: %v", deliveryError)
}
if requestCount != 1 {
testInstance.Fatalf("der Empfaenger bekam %d Anfragen, erwartet wurde eine", requestCount)
}
if receivedPayload.AlertID != testAlert.ID {
testInstance.Error("die Meldungskennung kam nicht an")
}
if receivedPayload.Severity != SeverityCritical {
testInstance.Errorf("der Schweregrad war %q, erwartet wurde critical", receivedPayload.Severity)
}
// Der Empfaenger muss pruefen koennen, dass die Meldung von dieser Anlage
// stammt.
if receivedHeader != "Bearer geheim-123" {
testInstance.Errorf("das Zugangsgeheimnis kam nicht an: %q", receivedHeader)
}
}
// TestWebhookRefusesPlainHTTPByDefault prueft den Schutz vor unverschluesselter Zustellung.
//
// Eine Meldung ueber unverschluesseltes HTTP verraet einem Mitleser, welche
// Anlage gerade nicht gesichert wird — eine Auskunft, die man nicht verschenkt.
func TestWebhookRefusesPlainHTTPByDefault(testInstance *testing.T) {
webhookChannel := Channel{
ChannelType: ChannelWebhook,
Configuration: map[string]any{"url": "http://leitstand.example.test/hook"},
}
deliverer := &WebhookDeliverer{httpClient: http.DefaultClient}
deliveryError := deliverer.Deliver(context.Background(), webhookChannel, "", buildTestAlert())
if deliveryError == nil {
testInstance.Fatal("die unverschluesselte Zustellung wurde angenommen")
}
if !strings.Contains(deliveryError.Error(), "allow_insecure") {
testInstance.Errorf("der Fehler nennt den Ausweg nicht: %v", deliveryError)
}
}
// TestWebhookReportsRejectionByReceiver prueft die Behandlung einer Ablehnung.
func TestWebhookReportsRejectionByReceiver(testInstance *testing.T) {
testServer := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
responseWriter.WriteHeader(http.StatusInternalServerError)
}))
defer testServer.Close()
webhookChannel := Channel{
ChannelType: ChannelWebhook,
Configuration: map[string]any{"url": testServer.URL, "allow_insecure": true},
}
deliverer := &WebhookDeliverer{httpClient: testServer.Client()}
deliveryError := deliverer.Deliver(context.Background(), webhookChannel, "", buildTestAlert())
if deliveryError == nil {
testInstance.Fatal("eine abgelehnte Zustellung wurde als Erfolg gemeldet")
}
// Der Fehler muss den Statuscode nennen: „Zustellung fehlgeschlagen" allein
// laesst offen, ob der Empfaenger nicht da war oder die Nachricht ablehnte.
if !strings.Contains(deliveryError.Error(), "500") {
testInstance.Errorf("der Fehler nennt den Statuscode nicht: %v", deliveryError)
}
}
// TestSeverityThresholdFiltersChannels prueft die Schwelle je Kanal.
func TestSeverityThresholdFiltersChannels(testInstance *testing.T) {
// Ohne diese Grenze bekaeme der Bereitschaftsdienst nachts jede Information.
// Nach einer Woche schaltet er die Benachrichtigungen ab, und dann kommt
// auch die kritische nicht mehr an.
if SeverityWarning.AtLeast(SeverityHigh) {
testInstance.Error("eine Warnung erreichte einen Kanal, der erst ab hoch zustellt")
}
if !SeverityCritical.AtLeast(SeverityHigh) {
testInstance.Error("ein kritischer Zustand erreichte den Kanal nicht")
}
if !SeverityHigh.AtLeast(SeverityHigh) {
testInstance.Error("die Schwelle selbst wurde nicht erreicht")
}
if !SeverityInformation.AtLeast(SeverityInformation) {
testInstance.Error("eine Auskunft erreichte den Auskunftskanal nicht")
}
}