e9ff2c4912
The away sinks fell back to the whole Body when Summary was empty. ntfy and telegram leave the box, and the 0.8B phraser drops fields regularly, so that fallback could push full detail off the machine. The dispatcher already strips detail from away sendables. This exports that one rule as delivery.AwayMessage and has both sinks use it, so a sink can't leak the body on its own either: empty Summary means a generic line plus the rule name, never the body. The two sink tests named TestSendFallsBackToBodyWhenSummaryEmpty asserted the old, wrong behaviour, so they are rewritten to assert the generic line. TestSendRejectsEmptyMessage is likewise replaced: an away message can no longer be empty, so the sink has nothing left to reject. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
411 lines
12 KiB
Go
411 lines
12 KiB
Go
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 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")
|
|
}
|
|
}
|
|
|
|
// ----------------------------- 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)
|
|
}
|
|
}
|