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 .") 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") } }