d09954d85d
net/http wraps every transport failure in *url.Error, whose Error() prints the request URL. Telegram accepts the bot token nowhere but the URL path, so a send failure wrote the live token into the daemon log. On 2026-08-01 homesrv could not reach api.telegram.org and did that once a minute for as long as the network stayed down. The token lives in deploy/telegram.env to stay out of the repo; putting it in `docker compose logs` undoes that. Both error sites now go through redact. The structural branch rewrites url.Error.URL and keeps the type, so errors.As still matches; anything else falls back to scrubbing the rendered message. No minimum-token-length guard: a one-character token would shred the message, but that beats leaking it. DESIGN.md still said the classifier cascade was the path that runs today with llmrouter wired nil, and gave the resident checkpoint as Qwen3.5-0.8B. Both stopped being true on 2026-07-31. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
486 lines
15 KiB
Go
486 lines
15 KiB
Go
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 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)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// 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)
|
|
}
|
|
}
|