package telegramsink import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "net/url" "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 contentType string auth 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.contentType = r.Header.Get("Content-Type") rs.auth = r.Header.Get("Authorization") rs.mu <- struct{}{} w.Header().Set("Content-Type", "application/json") w.WriteHeader(rs.status) if rs.respond != "" { _, _ = w.Write([]byte(rs.respond)) } else { _, _ = w.Write([]byte(`{"ok":true}`)) } }) } func (rs *recordingServer) snapshot() (method, path, body, contentType, auth string) { <-rs.mu method, path, body, contentType, auth = rs.method, rs.path, rs.body, rs.contentType, rs.auth rs.mu <- struct{}{} return } func nudgeSendable(sev loop.Severity, summary string) delivery.Sendable { return delivery.Sendable{ Channel: delivery.ChannelTelegram, 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.ChannelTelegram, Kind: delivery.KindReminder, ReminderID: 42, Body: "full reminder body with detail", Summary: summary, Ts: time.Now(), } } // sinkCfg — convenience for tests: BaseURL + minimal required fields. func sinkCfg(baseURL string) Config { return Config{BaseURL: baseURL, BotToken: "123:abc", ChatID: "42"} } // ----------------------------- config --------------------------------------- func TestNewRejectsEmptyBotToken(t *testing.T) { _, err := New(Config{ChatID: "42"}) if err == nil { t.Fatal("want error for empty BotToken") } } func TestNewRejectsEmptyChatID(t *testing.T) { _, err := New(Config{BotToken: "123:abc"}) if err == nil { t.Fatal("want error for empty ChatID") } } func TestValidateAllowsEmptySecretsOnlyWhenDisabled(t *testing.T) { cfg := Config{Disabled: true} 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") } } func TestNewDefaultTimeout(t *testing.T) { s, err := New(Config{BotToken: "123:abc", ChatID: "42"}) 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 TestNewRejectsBadBaseURL(t *testing.T) { _, err := New(Config{BotToken: "x", ChatID: "1", BaseURL: "://bad"}) if err == nil { t.Fatal("want error for bad BaseURL") } } func TestNewRejectsBadProxy(t *testing.T) { _, err := New(Config{BotToken: "x", ChatID: "1", Proxy: "://bad"}) if err == nil { t.Fatal("want error for bad Proxy") } } // ----------------------------- send shape ----------------------------------- func TestSendPostsToSendMessagePath(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, err := New(sinkCfg(srv.URL)) if err != nil { t.Fatalf("New: %v", err) } if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "homesrv down")); 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 != "/bot123:abc/sendMessage" { t.Fatalf("path: want /bot123:abc/sendMessage, got %s", path) } } func TestSendBodyIsSummaryNotFullBody(t *testing.T) { // 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(sinkCfg(srv.URL)) if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "homesrv down")); err != nil { t.Fatalf("Send: %v", err) } _, _, body, _, _ := rs.snapshot() var req sendMessageReq if err := json.Unmarshal([]byte(body), &req); err != nil { t.Fatalf("unmarshal body: %v (raw=%q)", err, body) } if req.Text != "homesrv down" { t.Fatalf("text: want summary 'homesrv down', got %q", req.Text) } } func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) { // #368: this used to fall back to the full body. telegram leaves the box, // so an empty summary gets a fixed generic line plus the rule name. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() s := nudgeSendable(loop.Sev4, "") sink, _ := New(sinkCfg(srv.URL)) if err := sink.Send(context.Background(), s); err != nil { t.Fatalf("Send: %v", err) } _, _, body, _, _ := rs.snapshot() var req sendMessageReq _ = json.Unmarshal([]byte(body), &req) want := delivery.GenericAwayMessage + ": service_down" if req.Text != want { t.Fatalf("text: want %q, got %q", want, req.Text) } } func TestSendNeverSendsAnEmptyMessage(t *testing.T) { // with nothing at all to say we still send the generic line. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(sinkCfg(srv.URL)) s := nudgeSendable(loop.Sev4, "") s.Body = "" s.RuleName = "" if err := sink.Send(context.Background(), s); err != nil { t.Fatalf("Send: %v", err) } _, _, body, _, _ := rs.snapshot() var req sendMessageReq _ = json.Unmarshal([]byte(body), &req) if req.Text != delivery.GenericAwayMessage { t.Fatalf("text: want %q, got %q", delivery.GenericAwayMessage, req.Text) } } func TestSendSetsChatIDAndProtectContent(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() cfg := sinkCfg(srv.URL) cfg.ChatID = "@maven_alerts" sink, _ := New(cfg) if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil { t.Fatalf("Send: %v", err) } _, _, body, _, _ := rs.snapshot() var req sendMessageReq if err := json.Unmarshal([]byte(body), &req); err != nil { t.Fatalf("unmarshal: %v", err) } if req.ChatID != "@maven_alerts" { t.Fatalf("chat_id: want @maven_alerts, got %q", req.ChatID) } if !req.ProtectContent { t.Fatalf("protect_content: want true, got false") } if req.DisableNotification { t.Fatalf("disable_notification: want false (alarms ring), got true") } } func TestSendContentTypeIsJSON(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(sinkCfg(srv.URL)) _ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) _, _, _, ct, _ := rs.snapshot() if !strings.Contains(ct, "application/json") { t.Fatalf("content-type: want application/json, got %q", ct) } } func TestSendNoBasicAuthHeader(t *testing.T) { // telegram uses the bot token in the URL path, NOT a Basic auth header. // a Basic header would be a token-leak surface: proxies log headers, URLs // less so. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(sinkCfg(srv.URL)) _ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) _, _, _, _, auth := rs.snapshot() if auth != "" { t.Fatalf("want no Authorization header (token in URL), got %q", auth) } } // ----------------------------- error handling ------------------------------- func TestSendReturnsErrorOnTelegramError(t *testing.T) { // telegram returns non-2xx with JSON {"ok":false,"error_code":401,...} on // auth failure. the sink must surface error_code + description. rs := newRecordingServer(t, http.StatusUnauthorized, `{"ok":false,"error_code":401,"description":"Unauthorized"}`) srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(sinkCfg(srv.URL)) err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) if err == nil { t.Fatal("want error on telegram 401") } if !strings.Contains(err.Error(), "401") { t.Fatalf("error should mention error_code 401, got: %v", err) } if !errors.Is(err, delivery.ErrPermanent) { t.Fatalf("revoked bot credential must be permanent, got: %v", err) } } // A relay that is up but cannot reach api.telegram.org answers 200 with a page // of its own. That is not a delivered message, and calling it one silences a // sev4 alarm for a full repeat interval. func TestSendRefusesA200ThatIsNotTheBotAPIEnvelope(t *testing.T) { rs := newRecordingServer(t, http.StatusOK, `proxy: upstream unreachable`) srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(sinkCfg(srv.URL)) err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) if err == nil { t.Fatal("want error on a 200 that is not the bot API envelope") } if !strings.Contains(err.Error(), "not a confirmed send") { t.Fatalf("error should say the send is unconfirmed, got: %v", err) } } // The success path must stay a success: ok=true on 200 is a send. func TestSendAcceptsOkTrue(t *testing.T) { rs := newRecordingServer(t, http.StatusOK, `{"ok":true,"result":{"message_id":7}}`) srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(sinkCfg(srv.URL)) if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil { t.Fatalf("want success on ok=true, got: %v", err) } } // A body long enough to have been truncated by the old 4096-byte cap still // parses, so a real send is not reported as a failure. func TestSendAcceptsAnOversizedButValidEnvelope(t *testing.T) { rs := newRecordingServer(t, http.StatusOK, `{"ok":true,"result":{"message_id":7,"text":"`+strings.Repeat("x", 8000)+`"}}`) srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(sinkCfg(srv.URL)) if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil { t.Fatalf("want success on a large ok=true envelope, got: %v", err) } } // The error line carries a snippet, not the relay's whole page. func TestSendErrorDoesNotCarryTheWholeBody(t *testing.T) { rs := newRecordingServer(t, http.StatusBadGateway, strings.Repeat("z", 5000)) srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(sinkCfg(srv.URL)) err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) if err == nil { t.Fatal("want error on 502") } if len(err.Error()) > 500 { t.Fatalf("error line should be trimmed, got %d bytes", len(err.Error())) } } func TestSendReturnsErrorOnNon2xx(t *testing.T) { rs := newRecordingServer(t, http.StatusBadGateway, "bad gateway") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(sinkCfg(srv.URL)) err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) if err == nil { t.Fatal("want error on 502") } } func TestSendContextCancelReturnsError(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(sinkCfg(srv.URL)) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) defer cancel() err := sink.Send(ctx, nudgeSendable(loop.Sev4, "down")) if err == nil { t.Fatal("want error on canceled context") } } func TestSendConnectionRefusedReturnsError(t *testing.T) { cfg := sinkCfg("http://127.0.0.1:1") cfg.Timeout = time.Second sink, _ := New(cfg) err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) if err == nil { t.Fatal("want error on connection refused") } } // --------------------------- token redaction -------------------------------- // realToken — shaped like a real BotFather token, unlike sinkCfg's "123:abc". // The redaction tests need something long and distinctive enough that finding // it in an error message is unambiguous. const realToken = "7556767480:AAFh0vLU9sg8l7DwXU9y-VZQquSJKW3lsVQ" // A transport error renders the whole request URL, and telegram accepts the // token nowhere but the URL path. On 2026-08-01 that put the live bot token in // `docker compose logs mavend` once a minute while egress was down. func TestSendTransportErrorRedactsToken(t *testing.T) { cases := []struct { name string run func(*Sink) error }{ {"connection refused", func(s *Sink) error { return s.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) }}, {"context cancel", func(s *Sink) error { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) defer cancel() return s.Send(ctx, nudgeSendable(loop.Sev4, "down")) }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { cfg := sinkCfg("http://127.0.0.1:1") cfg.BotToken = realToken cfg.Timeout = time.Second sink, _ := New(cfg) err := tc.run(sink) if err == nil { t.Fatal("want a transport error") } if strings.Contains(err.Error(), realToken) { t.Fatalf("token leaked into error: %v", err) } if !strings.Contains(err.Error(), tokenPlaceholder) { t.Fatalf("want %q in the redacted error, got: %v", tokenPlaceholder, err) } }) } } // The structural branch keeps the error's type so errors.As still matches. func TestRedactPreservesURLErrorType(t *testing.T) { cfg := sinkCfg("http://127.0.0.1:1") cfg.BotToken = realToken cfg.Timeout = time.Second sink, _ := New(cfg) err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")) var ue *url.Error if !errors.As(err, &ue) { t.Fatalf("want *url.Error to survive redaction, got %T: %v", err, err) } if strings.Contains(ue.URL, realToken) { t.Fatalf("token left in url.Error.URL: %s", ue.URL) } } // Nothing to redact must not disturb the error. func TestRedactLeavesCleanErrorsAlone(t *testing.T) { sink, _ := New(sinkCfg("http://127.0.0.1:1")) in := errors.New("dial tcp: no route to host") if got := sink.redact(in); got != in { t.Fatalf("want the same error back, got %v", got) } if sink.redact(nil) != nil { t.Fatal("want nil for nil") } } // ----------------------------- proxy seam ----------------------------------- func TestProxyWiredIntoTransport(t *testing.T) { // region restriction: homesrv can't reach api.telegram.org directly. // the proxy URL configured must land on the http.Transport.Proxy so the // stdlib dials the relay first. this is the only thing the sink needs to // do for the region cut — Transport.Proxy handles HTTP/HTTPS/SOCKS5. sink, err := New(Config{ BotToken: "123:abc", ChatID: "42", Proxy: "socks5://127.0.0.1:1080", }) if err != nil { t.Fatalf("New: %v", err) } tr, ok := sink.hc.Transport.(*http.Transport) if !ok { t.Fatalf("transport: want *http.Transport, got %T", sink.hc.Transport) } if tr.Proxy == nil { t.Fatal("transport.Proxy not set") } pu, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "api.telegram.org"}}) if err != nil { t.Fatalf("proxy fn: %v", err) } if pu == nil || pu.Host != "127.0.0.1:1080" || pu.Scheme != "socks5" { t.Fatalf("proxy url: want socks5://127.0.0.1:1080, got %v", pu) } } func TestSendRoutesThroughProxyWhenConfigured(t *testing.T) { // end-to-end: a fake proxy (httptest) records that the transport routes // through it. the fake telegram API is rigged to t.Fatalf if reached // directly — proving the proxy actually carried the request, not just that // Transport.Proxy is set (covered by the previous test). proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) _, _ = w.Write([]byte(`{"ok":true}`)) })) defer proxySrv.Close() telegramSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatalf("direct telegram API reached — proxy was bypassed") })) defer telegramSrv.Close() cfg := sinkCfg(telegramSrv.URL) cfg.Proxy = proxySrv.URL sink, err := New(cfg) if err != nil { t.Fatalf("New: %v", err) } if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil { t.Fatalf("Send: %v", err) } // reaching here means the proxy carried the request; the direct telegram // endpoint's handler never fired. } // ----------------------------- reminder same shape -------------------------- func TestReminderSendUsesSamePath(t *testing.T) { // Telegram is the second away alternative for reminders. If ntfy is // unavailable and the dispatcher falls through, the sink must accept // KindReminder undamaged. exercises the kind-agnostic contract. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(sinkCfg(srv.URL)) if err := sink.Send(context.Background(), reminderSendable("wake up")); err != nil { t.Fatalf("Send: %v", err) } _, _, body, _, _ := rs.snapshot() var req sendMessageReq _ = json.Unmarshal([]byte(body), &req) if req.Text != "wake up" { t.Fatalf("reminder text: want 'wake up', got %q", req.Text) } }