package ntfysink import ( "context" "errors" "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(), } } func tokenConfig(baseURL string) Config { return Config{BaseURL: baseURL, Topic: "maven", Token: "scoped-write-token"} } // ----------------------------- 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(tokenConfig("http://localhost:8085")) 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) } } func TestNewRejectsAnonymousPublishing(t *testing.T) { _, err := New(Config{BaseURL: "http://localhost:8085", Topic: "maven"}) if err == nil { t.Fatal("New accepted a topic with no credential") } if !strings.Contains(err.Error(), "required while enabled") { t.Fatalf("error should identify the enabled reach: %v", err) } } func TestValidateAllowsEmptyCredentialOnlyWhenDisabled(t *testing.T) { cfg := Config{Disabled: true, BaseURL: "http://localhost:8085", Topic: "maven"} if err := Validate(cfg); err != nil { t.Fatalf("Validate disabled config: %v", err) } if _, err := New(cfg); err == nil { t.Fatal("New built a live sink from a disabled config") } } // ----------------------------- send shape ---------------------------------- func TestSendPostsToTopicPath(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, err := New(tokenConfig(srv.URL)) 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(tokenConfig(srv.URL)) 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 TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) { // #368: this used to fall back to the full body. ntfy leaves the box, so // an empty summary gets a fixed generic line plus the rule name instead. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() s := nudgeSendable(loop.Sev3, "") sink, _ := New(tokenConfig(srv.URL)) if err := sink.Send(context.Background(), s); err != nil { t.Fatalf("Send: %v", err) } _, _, body, _, _, _ := rs.snapshot() want := delivery.GenericAwayMessage + ": service_down" if body != want { t.Fatalf("body: want %q, got %q", want, body) } } func TestSendNeverSendsAnEmptyMessage(t *testing.T) { // with nothing at all to say we still send the generic line — an away // channel can never carry detail, but it also never goes out blank. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(tokenConfig(srv.URL)) s := nudgeSendable(loop.Sev3, "") s.Body = "" s.RuleName = "" if err := sink.Send(context.Background(), s); err != nil { t.Fatalf("Send: %v", err) } _, _, body, _, _, _ := rs.snapshot() if body != delivery.GenericAwayMessage { t.Fatalf("body: want %q, got %q", delivery.GenericAwayMessage, body) } } 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 TestTokenConfigSendsBearerAuth(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(tokenConfig(srv.URL)) if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil { t.Fatalf("Send: %v", err) } _, _, _, auth, _, _ := rs.snapshot() if auth != "Bearer scoped-write-token" { t.Fatalf("want bearer auth header, got %q", auth) } } // TestSendSetsBearerToken — the deployed credential (V-649) is an ntfy access // token scoped write-only to the maven topic, not a password. A token sent as // basic auth is rejected by ntfy, so the header shape is the whole test. func TestSendSetsBearerToken(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven", Token: "tk_secret"}) if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil { t.Fatalf("Send: %v", err) } _, _, _, auth, _, _ := rs.snapshot() if auth != "Bearer tk_secret" { t.Fatalf("auth: want 'Bearer tk_secret', got %q", auth) } } // TestNewRejectsBothCredentials — configuring a token and a username means one // of them was meant and the other is a leftover. Picking either would leave a // server that authenticates against a credential nobody wrote down. func TestNewRejectsBothCredentials(t *testing.T) { _, err := New(Config{BaseURL: "http://x", Topic: "maven", Token: "tk_x", Username: "maven"}) if err == nil { t.Fatal("New accepted both a token and a username") } if !strings.Contains(err.Error(), "not both") { t.Errorf("error does not say which to fix: %v", err) } } func TestSendTitleIsMaven(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(tokenConfig(srv.URL)) 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(tokenConfig(srv.URL)) _ = 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(tokenConfig(srv.URL)) _ = 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(tokenConfig(srv.URL)) _ = 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(tokenConfig(srv.URL)) 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) } if !errors.Is(err, delivery.ErrPermanent) { t.Fatalf("403 = %v; want delivery.ErrPermanent", err) } } func TestSendServerFailureRemainsRetryable(t *testing.T) { rs := newRecordingServer(t, http.StatusServiceUnavailable, `{"error":"temporarily unavailable"}`) srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(tokenConfig(srv.URL)) err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")) if err == nil { t.Fatal("want error on 503") } if errors.Is(err, delivery.ErrPermanent) { t.Fatalf("503 was marked permanent: %v", err) } } func TestSendContextCancelReturnsError(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(tokenConfig(srv.URL)) 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) { cfg := tokenConfig("http://127.0.0.1:1") cfg.Timeout = time.Second sink, _ := New(cfg) err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")) if err == nil { t.Fatal("want error on connection refused") } }