233 lines
9.4 KiB
Go
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{}
|
|
}
|
|
}
|