Files
Maven/internal/delivery/telegramsink/telegramsink_test.go
T
2026-07-03 00:32:48 +02:00

402 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 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)
}
}