35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
397 lines
12 KiB
Go
397 lines
12 KiB
Go
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")
|
|
}
|
|
}
|