e9ff2c4912
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
321 lines
9.1 KiB
Go
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")
|
|
}
|
|
}
|