package telegramsink import ( "context" "encoding/json" "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 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 TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) { // terse full message beats none; the phraser should produce a summary for // away-bound severities, but don't silently drop. 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) if req.Text != s.Body { t.Fatalf("fallback text: want %q, got %q", s.Body, req.Text) } } func TestSendRejectsEmptyMessage(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() sink, _ := New(sinkCfg(srv.URL)) s := nudgeSendable(loop.Sev4, "") s.Body = "" err := sink.Send(context.Background(), s) if err == nil { t.Fatal("want error for empty message") } } 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) } } 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") } } // ----------------------------- 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) { // reminders away route to ntfy, not telegram — but if the daemon ever // routes a reminder via telegram (per-reminder override), 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) } }