package ntfysink import ( "context" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/loop" ) // recordingServer — captures the last request so tests assert the wire shape. type recordingServer struct { mu chan struct{} method string path string body string auth string title string prio string status int respond string } func newRecordingServer(t *testing.T, status int, respond string) *recordingServer { t.Helper() rs := &recordingServer{status: status, respond: respond, mu: make(chan struct{}, 1)} rs.mu <- struct{}{} return rs } func (rs *recordingServer) handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) <-rs.mu rs.method = r.Method rs.path = r.URL.Path rs.body = string(b) rs.auth = r.Header.Get("Authorization") rs.title = r.Header.Get("Title") rs.prio = r.Header.Get("Priority") rs.mu <- struct{}{} w.WriteHeader(rs.status) if rs.respond != "" { _, _ = w.Write([]byte(rs.respond)) } }) } func (rs *recordingServer) snapshot() (method, path, body, auth, title, prio string) { <-rs.mu method, path, body, auth, title, prio = rs.method, rs.path, rs.body, rs.auth, rs.title, rs.prio rs.mu <- struct{}{} return } func nudgeSendable(sev loop.Severity, summary string) delivery.Sendable { return delivery.Sendable{ Channel: delivery.ChannelNtfy, Kind: delivery.KindNudge, Severity: sev, RuleName: "service_down", Body: "the backup service on homesrv is down - check journalctl", Summary: summary, Ts: time.Now(), } } func reminderSendable(summary string) delivery.Sendable { return delivery.Sendable{ Channel: delivery.ChannelNtfy, Kind: delivery.KindReminder, ReminderID: 42, Body: "full reminder body with detail", Summary: summary, Ts: time.Now(), } } // ----------------------------- config --------------------------------------- func TestNewRejectsEmptyBaseURL(t *testing.T) { _, err := New(Config{Topic: "maven"}) if err == nil { t.Fatal("want error for empty BaseURL") } } func TestNewRejectsEmptyTopic(t *testing.T) { _, err := New(Config{BaseURL: "http://localhost:8085"}) if err == nil { t.Fatal("want error for empty Topic") } } func TestNewDefaultTimeout(t *testing.T) { s, err := New(Config{BaseURL: "http://localhost:8085", Topic: "maven"}) if err != nil { t.Fatalf("New: %v", err) } if s.hc.Timeout != DefaultTimeout { t.Fatalf("default timeout: want %v, got %v", DefaultTimeout, s.hc.Timeout) } } // ----------------------------- send shape ---------------------------------- func TestSendPostsToTopicPath(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, err := New(Config{BaseURL: srv.URL, Topic: "maven"}) if err != nil { t.Fatalf("New: %v", err) } if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert expiring soon")); err != nil { t.Fatalf("Send: %v", err) } method, path, _, _, _, _ := rs.snapshot() if method != http.MethodPost { t.Fatalf("method: want POST, got %s", method) } if path != "/maven" { t.Fatalf("path: want /maven, got %s", path) } } func TestSendBodyIsSummaryNotFullBody(t *testing.T) { // the minimal-body rule: away channels get Summary, never Body. the sink // must post Summary so a phraser bug (Body leaking detail) can't exfil. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert expiring soon")); err != nil { t.Fatalf("Send: %v", err) } _, _, body, _, _, _ := rs.snapshot() if body != "cert expiring soon" { t.Fatalf("body: want summary 'cert expiring soon', got %q", body) } } func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) { // a terse full message is better than no message; the phraser should // produce a summary for away-bound severities, but don't silently drop. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() s := nudgeSendable(loop.Sev3, "") sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) if err := sink.Send(context.Background(), s); err != nil { t.Fatalf("Send: %v", err) } _, _, body, _, _, _ := rs.snapshot() if body != s.Body { t.Fatalf("fallback body: want %q, got %q", s.Body, body) } } func TestSendRejectsEmptyMessage(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) s := nudgeSendable(loop.Sev3, "") s.Body = "" err := sink.Send(context.Background(), s) if err == nil { t.Fatal("want error for empty message") } } func TestSendSetsBasicAuth(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(Config{ BaseURL: srv.URL, Topic: "maven", Username: "maven", Password: "secret", }) if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil { t.Fatalf("Send: %v", err) } _, _, _, auth, _, _ := rs.snapshot() if auth == "" { t.Fatal("want Basic auth header, got empty") } if !strings.HasPrefix(auth, "Basic ") { t.Fatalf("auth: want 'Basic ...', got %q", auth) } } func TestSendNoAuthWhenUsernameEmpty(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil { t.Fatalf("Send: %v", err) } _, _, _, auth, _, _ := rs.snapshot() if auth != "" { t.Fatalf("want no auth header, got %q", auth) } } func TestSendTitleIsMaven(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil { t.Fatalf("Send: %v", err) } _, _, _, _, title, _ := rs.snapshot() if title != "maven" { t.Fatalf("title: want 'maven', got %q", title) } } // ----------------------------- priority mapping ---------------------------- func TestPrioritySev3IsHigh(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) _ = sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert")) _, _, _, _, _, prio := rs.snapshot() if prio != "4" { t.Fatalf("sev3 priority: want 4 (high), got %s", prio) } } func TestPrioritySev4IsMax(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) _ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) _, _, _, _, _, prio := rs.snapshot() if prio != "5" { t.Fatalf("sev4 priority: want 5 (max), got %s", prio) } } func TestPriorityReminderIsHigh(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) _ = sink.Send(context.Background(), reminderSendable("wake up")) _, _, _, _, _, prio := rs.snapshot() if prio != "4" { t.Fatalf("reminder priority: want 4 (high), got %s", prio) } } // ----------------------------- error handling ------------------------------ func TestSendReturnsErrorOnNon2xx(t *testing.T) { rs := newRecordingServer(t, http.StatusForbidden, `{"error":"forbidden"}`) srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")) if err == nil { t.Fatal("want error on 403") } if !strings.Contains(err.Error(), "403") { t.Fatalf("error should mention status 403, got: %v", err) } } func TestSendContextCancelReturnsError(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) defer cancel() err := sink.Send(ctx, nudgeSendable(loop.Sev3, "down")) if err == nil { t.Fatal("want error on canceled context") } } func TestSendConnectionRefusedReturnsError(t *testing.T) { sink, _ := New(Config{BaseURL: "http://127.0.0.1:1", Topic: "maven", Timeout: time.Second}) err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")) if err == nil { t.Fatal("want error on connection refused") } }