Files
rmm-openwrt/server/internal/httpapi/notifications_test.go
2026-07-23 00:17:38 +03:00

233 lines
9.4 KiB
Go

package httpapi_test
import (
"context"
"database/sql"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"rmm-openwrt/server/internal/httpapi"
"rmm-openwrt/server/internal/model"
"rmm-openwrt/server/internal/store"
_ "modernc.org/sqlite"
)
type capturedNotification struct {
destination string
title string
body string
}
type captureNotificationSender struct {
messages chan capturedNotification
}
type failingNotificationSender struct{}
func (failingNotificationSender) SendNotification(context.Context, string, string, string) error {
return errors.New("smtp.internal.example: authentication secret rejected")
}
func (s *captureNotificationSender) SendNotification(_ context.Context, destination, title, body string) error {
s.messages <- capturedNotification{destination: destination, title: title, body: body}
return nil
}
func TestNotificationSettingsTestAndAlertLifecycle(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "notifications.db")
st, err := store.OpenSQLite(context.Background(), dbPath)
if err != nil {
t.Fatal(err)
}
defer st.Close()
sender := &captureNotificationSender{messages: make(chan capturedNotification, 8)}
srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{
OperatorUsername: "admin",
OperatorPassword: "notification-password-123",
PublicURL: "https://rmm.example.test",
AlertEmailSender: sender,
}))
defer srv.Close()
client := authenticatedClient(t, srv.URL, "admin", "notification-password-123")
authRequestJSON(t, client, http.MethodPatch, srv.URL+"/api/auth/profile", map[string]any{
"display_name": "Owner", "email": "owner@example.test",
}, http.StatusOK, nil)
var defaults struct {
Settings model.NotificationSettings `json:"settings"`
}
authRequestJSON(t, client, http.MethodGet, srv.URL+"/api/notifications/settings", nil, http.StatusOK, &defaults)
if defaults.Settings.Configured || defaults.Settings.MemoryThresholdPercent != 85 {
t.Fatalf("unexpected defaults: %#v", defaults.Settings)
}
var saved struct {
Settings model.NotificationSettings `json:"settings"`
}
authRequestJSON(t, client, http.MethodPut, srv.URL+"/api/notifications/settings", map[string]any{
"email_enabled": true, "telegram_enabled": false, "notify_warning": true,
"notify_critical": true, "notify_resolved": true, "memory_threshold_percent": 80,
"disk_threshold_percent": 90, "packet_loss_percent": 25, "latency_threshold_ms": 250,
"repeat_minutes": 15,
}, http.StatusOK, &saved)
if !saved.Settings.Configured || !saved.Settings.EmailEnabled || saved.Settings.MemoryThresholdPercent != 80 {
t.Fatalf("settings were not saved: %#v", saved.Settings)
}
authRequestJSON(t, client, http.MethodPost, srv.URL+"/api/notifications/test", nil, http.StatusOK, nil)
testMessage := waitNotification(t, sender.messages)
if testMessage.destination != "owner@example.test" || testMessage.title == "" || testMessage.body == "" {
t.Fatalf("unexpected test notification: %#v", testMessage)
}
enrolled := enrollForClient(t, srv.URL, client, "notify-router", "notify-router")
requestJSON(t, http.MethodPost, srv.URL+"/api/agent/heartbeat", enrolled.DeviceToken, map[string]any{
"device_id": enrolled.DeviceID,
"inventory": map[string]any{"hostname": "notify-router"},
"metrics": map[string]any{"memory": map[string]any{"used_kb": 900, "total_kb": 1000}},
}, http.StatusOK, nil)
activeMessage := waitNotification(t, sender.messages)
if activeMessage.title == "" {
t.Fatal("active alert notification was empty")
}
waitNotificationDelivery(t, client, srv.URL, "active", "sent")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatal(err)
}
old := time.Now().UTC().Add(-16 * time.Minute).Format(time.RFC3339Nano)
if _, err := db.Exec(`UPDATE notification_deliveries SET created_at = ?, updated_at = ? WHERE event = 'active'`, old, old); err != nil {
db.Close()
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
requestJSON(t, http.MethodPost, srv.URL+"/api/agent/heartbeat", enrolled.DeviceToken, map[string]any{
"device_id": enrolled.DeviceID,
"inventory": map[string]any{"hostname": "notify-router"},
"metrics": map[string]any{"memory": map[string]any{"used_kb": 900, "total_kb": 1000}},
}, http.StatusOK, nil)
repeatMessage := waitNotification(t, sender.messages)
if repeatMessage.title == "" {
t.Fatal("repeat alert notification was empty")
}
waitNotificationDelivery(t, client, srv.URL, "repeat", "sent")
requestJSON(t, http.MethodPost, srv.URL+"/api/agent/heartbeat", enrolled.DeviceToken, map[string]any{
"device_id": enrolled.DeviceID,
"inventory": map[string]any{"hostname": "notify-router"},
"metrics": map[string]any{"memory": map[string]any{"used_kb": 100, "total_kb": 1000}},
}, http.StatusOK, nil)
resolvedMessage := waitNotification(t, sender.messages)
if resolvedMessage.title == activeMessage.title {
t.Fatalf("resolved notification did not change state: active=%q resolved=%q", activeMessage.title, resolvedMessage.title)
}
var history struct {
Notifications []model.NotificationDelivery `json:"notifications"`
}
authRequestJSON(t, client, http.MethodGet, srv.URL+"/api/notifications", nil, http.StatusOK, &history)
if len(history.Notifications) < 4 {
t.Fatalf("expected test, active, repeat and resolved deliveries, got %#v", history.Notifications)
}
for _, delivery := range history.Notifications {
if delivery.Destination != "" {
t.Fatal("raw notification destination leaked through API")
}
}
}
func waitNotificationDelivery(t *testing.T, client *http.Client, serverURL, event, status string) model.NotificationDelivery {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
var history struct {
Notifications []model.NotificationDelivery `json:"notifications"`
}
authRequestJSON(t, client, http.MethodGet, serverURL+"/api/notifications", nil, http.StatusOK, &history)
for _, delivery := range history.Notifications {
if delivery.Event == event && delivery.Status == status {
return delivery
}
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("notification event %q did not reach status %q", event, status)
return model.NotificationDelivery{}
}
func TestNotificationSettingsValidation(t *testing.T) {
st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "notification-validation.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{OperatorUsername: "admin", OperatorPassword: "notification-password-123"}))
defer srv.Close()
client := authenticatedClient(t, srv.URL, "admin", "notification-password-123")
authRequestJSON(t, client, http.MethodPut, srv.URL+"/api/notifications/settings", map[string]any{
"email_enabled": true, "notify_warning": true, "notify_critical": true, "notify_resolved": true,
"memory_threshold_percent": 85, "disk_threshold_percent": 85, "packet_loss_percent": 20,
"latency_threshold_ms": 200, "repeat_minutes": 0,
}, http.StatusBadRequest, nil)
authRequestJSON(t, client, http.MethodPut, srv.URL+"/api/notifications/settings", map[string]any{
"telegram_enabled": true, "telegram_chat_id": "not-a-chat", "notify_warning": true,
"notify_critical": true, "notify_resolved": true, "memory_threshold_percent": 85,
"disk_threshold_percent": 85, "packet_loss_percent": 20, "latency_threshold_ms": 200,
"repeat_minutes": 0,
}, http.StatusBadRequest, nil)
}
func TestNotificationFailureIsRetriedWithoutLeakingProviderError(t *testing.T) {
st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "notification-failure.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{
OperatorUsername: "admin", OperatorPassword: "notification-password-123",
AlertEmailSender: failingNotificationSender{}, NotificationMaxAttempts: 3,
}))
defer srv.Close()
client := authenticatedClient(t, srv.URL, "admin", "notification-password-123")
authRequestJSON(t, client, http.MethodPatch, srv.URL+"/api/auth/profile", map[string]any{
"display_name": "Owner", "email": "owner@example.test",
}, http.StatusOK, nil)
authRequestJSON(t, client, http.MethodPut, srv.URL+"/api/notifications/settings", map[string]any{
"email_enabled": true, "notify_warning": true, "notify_critical": true, "notify_resolved": true,
"memory_threshold_percent": 85, "disk_threshold_percent": 85, "packet_loss_percent": 20,
"latency_threshold_ms": 200, "repeat_minutes": 0,
}, http.StatusOK, nil)
var result struct {
Notifications []model.NotificationDelivery `json:"notifications"`
}
authRequestJSON(t, client, http.MethodPost, srv.URL+"/api/notifications/test", nil, http.StatusOK, &result)
if len(result.Notifications) != 1 {
t.Fatalf("unexpected test result: %#v", result.Notifications)
}
delivery := result.Notifications[0]
if delivery.Status != "retry" || delivery.AttemptCount != 1 || delivery.MaxAttempts != 3 || delivery.NextAttemptAt == nil {
t.Fatalf("unexpected retry state: %#v", delivery)
}
if strings.Contains(delivery.Error, "smtp.internal") || strings.Contains(delivery.Error, "secret") {
t.Fatalf("provider error leaked to API: %q", delivery.Error)
}
}
func waitNotification(t *testing.T, messages <-chan capturedNotification) capturedNotification {
t.Helper()
select {
case message := <-messages:
return message
case <-time.After(2 * time.Second):
t.Fatal("notification was not delivered")
return capturedNotification{}
}
}