Files
Maven/internal/delivery/ntfysink/ntfysink_test.go
T
claude 08889cad88 Give the box a second reach (V-649)
Telegram was the only way off this box, and it is not a direct path: it
needs api.telegram.org, a socks relay on the host and a matching ufw rule.
Each of those three has failed once, and when they do a sev4 nudge has
nowhere to go. ntfy shares none of them.

The spare is the smaller half of it. The routing table already sends
sev3-away nudges and away reminders to ntfy and to nothing else, so with no
block configured those two routes hit a nil sink in DispatchNudge and
DispatchReminder and are skipped — no log line, no delivery_attempts row.
An away reminder is worse than dropped: out stays empty, so MarkReminder
never runs and it re-fires every tick without ever being delivered.

Owner's call, 07-08-2026: ntfy.kvmx.ru, topic maven.

The sink now takes a bearer token, which is what that server wants and what
it could not do before. ntfy scopes a token to one topic and to write-only,
so a popped sink can push to the maven topic and cannot read it back. Basic
auth stays for a server with no tokens; configuring both is refused rather
than resolved by guessing.

Config keys got json tags. docs/operations.md has documented this block as
base_url/topic since before it existed, and the untagged struct would only
have answered to BaseURL/Topic — the documented config would have parsed
into an empty one.

The token is a ${NTFY_TOKEN} expansion from the gitignored
deploy/telegram.env, beside the telegram secrets. TestDeployConfigLoads now
fails if the block goes missing, because deleting it is how you turn the
reach off and the two silent routes are what that costs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMNNEkYx1mZFtHNrFk7uqb
2026-08-07 02:16:18 +04:00

352 lines
10 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)
}
}
// 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(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")
}
}