Files
Maven/internal/delivery/ntfysink/ntfysink_test.go
T
kami e9ff2c4912 Never send the full nudge body off-box (#368)
The away sinks fell back to the whole Body when Summary was empty. ntfy and
telegram leave the box, and the 0.8B phraser drops fields regularly, so that
fallback could push full detail off the machine.

The dispatcher already strips detail from away sendables. This exports that
one rule as delivery.AwayMessage and has both sinks use it, so a sink can't
leak the body on its own either: empty Summary means a generic line plus the
rule name, never the body.

The two sink tests named TestSendFallsBackToBodyWhenSummaryEmpty asserted the
old, wrong behaviour, so they are rewritten to assert the generic line.
TestSendRejectsEmptyMessage is likewise replaced: an away message can no
longer be empty, so the sink has nothing left to reject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 14:23:54 +04:00

321 lines
9.1 KiB
Go

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 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(Config{BaseURL: srv.URL, Topic: "maven"})
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(Config{BaseURL: srv.URL, Topic: "maven"})
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 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")
}
}