diff --git a/.env.dev.example b/.env.dev.example index 3ff1ead..590b64f 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -147,3 +147,29 @@ IMAGE_PROXY_MAX_SOURCE_SIZE_MB=10 OTEL_ENABLED=false # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 # OTEL_SERVICE_NAME=coves-appview-dev + +# ============================================================================= +# Telegram Operator Alerts (Optional) +# ============================================================================= +# Off in dev by default. Turn it on locally to check the alert text and your +# bot wiring end to end: submit a report from the app and the message should +# arrive within a second or two. +# +# Get a token from @BotFather, then read your chat ID from +# https://api.telegram.org/bot/getUpdates after messaging the bot. +# Enabling this without both values fails startup on purpose. +# +# Use a SEPARATE bot from production — a dev instance posting test reports into +# the channel you rely on for real ones is how a real alert gets ignored. +# +# Uncomment all three lines together — filling in the credentials while leaving +# TELEGRAM_ALERTS_ENABLED unset gives you a clean boot and no alerts. Group chat +# IDs are negative; a channel can be given as @channelname. +# TELEGRAM_ALERTS_ENABLED=true +# TELEGRAM_BOT_TOKEN= +# TELEGRAM_CHAT_ID= +# Comma-separated subset of csam,doxing,harassment,spam,illegal,other. +# Unset = alert on all of them. +# TELEGRAM_ALERT_REASONS= +# Values above 10 are capped by the AppView's own 10s backstop. +# TELEGRAM_TIMEOUT_SECONDS=5 diff --git a/.env.prod.example b/.env.prod.example index 4ae247c..4dd0c67 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -316,6 +316,58 @@ OTEL_ENABLED=false # OTEL_SERVICE_NAME=coves-appview # OTEL_TRACES_SAMPLER_ARG=0.1 +# ============================================================================= +# Telegram Operator Alerts (Optional) +# ============================================================================= +# Raises a Telegram message the moment a user files an admin report. +# +# Why this exists: reports are written to the admin_reports table and nothing +# else happens. With alerts off, a CSAM report is indistinguishable from a +# quiet week. Telegram is used rather than email for the *alerting* path +# because it has no deliverability surface — no SPF/DKIM, no spam folder, no +# domain reputation — and reaches a phone in seconds. +# +# Setup: +# 1. Message @BotFather on Telegram, /newbot, copy the token. +# 2. Send your new bot a message, then open +# https://api.telegram.org/bot/getUpdates and read result[].chat.id +# (group IDs are negative; a channel can be given as @channelname). +# +# IMPORTANT — production reads /opt/coves/.env, NOT .env.prod. `docker compose +# -f docker-compose.prod.yml up -d` is run from /opt/coves with no --env-file, +# so compose interpolates from /opt/coves/.env and nothing else. Values put +# anywhere else fall through to the compose defaults and alerting stays OFF +# while the appview boots perfectly healthy. Env changes also need +# `docker compose up -d --force-recreate appview` — they are inert on a running +# container. +# +# Uncomment ALL THREE lines together. Filling in the token and chat ID while +# leaving TELEGRAM_ALERTS_ENABLED unset is the most common way to deploy this +# and have it do nothing; the server logs a WARN when it detects exactly that, +# but the warning is easy to miss in a boot log. +# +# Enabling with a missing token or chat ID FAILS STARTUP by design. Note that +# startup validates PRESENCE, not validity: a revoked token, a wrong chat ID, or +# a group ID missing its -100 prefix all start cleanly and only surface on the +# first real report. After deploying, file a test report and confirm the message +# actually arrives. +# TELEGRAM_ALERTS_ENABLED=true +# TELEGRAM_BOT_TOKEN=CHANGE_ME_TELEGRAM_BOT_TOKEN +# TELEGRAM_CHAT_ID=CHANGE_ME_TELEGRAM_CHAT_ID + +# Which report categories raise an alert. Comma-separated subset of +# csam,doxing,harassment,spam,illegal,other. Leave unset to alert on all of +# them — the right default until the admin read endpoints exist, because a +# filtered-out report is one nobody ever sees. Narrow this once report volume +# makes alert fatigue the bigger risk; csam,doxing,illegal are the categories +# that carry a response clock. +# TELEGRAM_ALERT_REASONS=csam,doxing,illegal + +# Per-request timeout in seconds. Report submission blocks on this, so keep it +# short (default: 5). Values above 10 have no effect — the AppView applies its +# own 10s backstop around the whole alert. +# TELEGRAM_TIMEOUT_SECONDS=5 + # ============================================================================= # Optional: Versioning # ============================================================================= diff --git a/.gitignore b/.gitignore index 78a2ce4..2935659 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,8 @@ scripts/.env.lexicon # Artifacts from `make ci`: the raw go test -json stream, the machine-readable # gate summary, and service logs captured on failure. Per-run output, not source. .ci-out/ + +# Telegram bot token, held locally only long enough to read a chat ID out of +# getUpdates. The real home for this value is /opt/coves/.env on the production +# host — delete the local copy once the deploy is done. +coves_admin_api.txt diff --git a/cmd/server/wiring.go b/cmd/server/wiring.go index 3eb9bbd..2f704d7 100644 --- a/cmd/server/wiring.go +++ b/cmd/server/wiring.go @@ -22,11 +22,13 @@ import ( "Coves/internal/core/userblocks" "Coves/internal/core/users" "Coves/internal/core/votes" + "Coves/internal/notify/telegram" "context" "database/sql" "fmt" "log/slog" "net/http" + "strings" "sync" "time" @@ -342,7 +344,12 @@ func (a *application) buildServices(ctx context.Context) error { a.oauthClient, a.oauthStore, nil, ) a.userBlockService = userblocks.NewService(a.userBlockRepo, nil, a.oauthClient, a.oauthStore, nil) - a.adminReportService = adminreports.NewService(postgresRepo.NewAdminReportRepository(a.db)) + adminReportOptions, err := adminReportAlertOptions() + if err != nil { + return err + } + a.adminReportService = adminreports.NewService( + postgresRepo.NewAdminReportRepository(a.db), adminReportOptions...) a.communitySuggestionService = communitysuggestions.NewService( postgresRepo.NewCommunitySuggestionRepository(a.db)) @@ -357,6 +364,60 @@ func (a *application) buildServices(ctx context.Context) error { return nil } +// adminReportAlertOptions builds the operator-alert wiring for admin reports. +// +// Alerting is opt-in (TELEGRAM_ALERTS_ENABLED): most operators running their +// own Coves instance will not want it, and a deployment must not need a +// Telegram account to boot. When it is switched on, however, a misconfiguration +// fails startup rather than degrading to "no alerts" — a silent alerter is +// indistinguishable from a quiet week, which is the fault this exists to fix. +func adminReportAlertOptions() ([]adminreports.ServiceOption, error) { + cfg, err := telegram.ConfigFromEnv() + if err != nil { + return nil, fmt.Errorf("telegram alerts: %w", err) + } + + if !cfg.Enabled { + if cfg.CredentialsPresentWhileDisabled { + // Almost certainly a half-finished setup rather than a deliberate + // opt-out. Not fatal — disabling a noisy channel in a hurry is + // legitimate — but it must not pass as quietly as a real opt-out. + slog.Warn("Telegram credentials are configured but TELEGRAM_ALERTS_ENABLED is not true; "+ + "reports will be stored and NOBODY will be notified", + "fix", "set TELEGRAM_ALERTS_ENABLED=true") + } else { + slog.Info("admin report alerts disabled; reports are stored but nobody is notified", + "enable_with", "TELEGRAM_ALERTS_ENABLED=true") + } + return nil, nil + } + + reasons, err := adminreports.ParseAlertReasons(cfg.AlertReasons) + if err != nil { + return nil, fmt.Errorf("TELEGRAM_ALERT_REASONS: %w", err) + } + + client, err := telegram.NewClient(cfg) + if err != nil { + return nil, fmt.Errorf("telegram alerts: %w", err) + } + + // Log which reasons alert, never the token or chat ID. + alerting := "all reasons" + if len(reasons) > 0 { + names := make([]string, 0, len(reasons)) + for _, reason := range reasons { + names = append(names, string(reason)) + } + alerting = strings.Join(names, ",") + } + slog.Info("admin report alerts enabled", "channel", "telegram", "reasons", alerting) + + return []adminreports.ServiceOption{ + adminreports.WithNotifier(adminreports.NewMessageNotifier(client, reasons)), + }, nil +} + // buildDualAuth wires the middleware that accepts all three credential types: // sealed OAuth session tokens (users), PDS-signed service JWTs (aggregators), // and API keys (aggregator bots). diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6599d37..3ebc69d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -160,6 +160,19 @@ services: TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY} TURNSTILE_SECRET_KEY: ${TURNSTILE_SECRET_KEY} PDS_ADMIN_PASSWORD: ${PDS_ADMIN_PASSWORD} + + # Telegram operator alerts for user-filed admin reports (optional). + # Off by default: reports are stored either way, but with alerting off + # nothing tells anyone a report exists — a CSAM report then sits in + # admin_reports unread. Enabling with an incomplete config FAILS STARTUP + # rather than degrading to silence. + # TELEGRAM_ALERT_REASONS is a comma-separated subset of + # csam,doxing,harassment,spam,illegal,other; empty alerts on all of them. + TELEGRAM_ALERTS_ENABLED: ${TELEGRAM_ALERTS_ENABLED:-false} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} + TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} + TELEGRAM_ALERT_REASONS: ${TELEGRAM_ALERT_REASONS:-} + TELEGRAM_TIMEOUT_SECONDS: ${TELEGRAM_TIMEOUT_SECONDS:-5} volumes: # Image proxy cache (persistent across container restarts) - imageproxy-cache:/var/cache/coves/images diff --git a/internal/core/adminreports/notifier.go b/internal/core/adminreports/notifier.go new file mode 100644 index 0000000..0bed341 --- /dev/null +++ b/internal/core/adminreports/notifier.go @@ -0,0 +1,198 @@ +package adminreports + +import ( + "context" + "errors" + "fmt" + "strings" + "time" +) + +// Notifier raises an out-of-band alert when a report is filed. +// +// Reports land in a Postgres table that nobody is watching. Without an alert a +// CSAM report can sit unread indefinitely while the response clock runs, which +// is the failure this port exists to close. It is deliberately +// channel-agnostic — Telegram today, email or push later — and lives in the +// domain rather than in an adapter because *what an alert says* about a report +// is a domain decision, not a transport one. +type Notifier interface { + // NotifyNewReport alerts an operator that report has been filed. + // + // Delivery is best-effort by contract: the report row is already committed + // by the time this runs, so an error here is a failed alert, never a + // failed report. + NotifyNewReport(ctx context.Context, report *Report) error +} + +// MessageSender delivers a single plain-text message to an operator over some +// channel. Keeping the transport this narrow means the domain never learns +// what Telegram is, and any future channel satisfies it without touching this +// package. +type MessageSender interface { + SendMessage(ctx context.Context, text string) error +} + +// Notifier construction and dispatch errors. +var ( + // ErrNilReport indicates NotifyNewReport was handed no report. + ErrNilReport = errors.New("cannot raise an alert for a nil report") + + // ErrNoMessageSender indicates the notifier was built without a transport. + ErrNoMessageSender = errors.New("notifier has no message sender") + + // ErrUnknownAlertReason indicates a configured alert reason is not one of + // the valid report categories. + ErrUnknownAlertReason = errors.New("unknown alert reason") +) + +const ( + // maxAlertTargetURI bounds how much of the reported URI reaches the alert. + // + // TargetURI is caller-supplied and effectively unbounded: atURIPattern uses + // unbounded quantifiers and SubmitReportRequest.Validate caps only the + // explanation, so a pattern-valid multi-kilobyte URI passes validation and + // stores fine. Sent verbatim it would push the message past the delivery + // channel's size limit and the alert would be rejected outright — handing a + // reporter the ability to suppress the alert about their own report. The + // full URI is in the row; the alert only needs enough to recognise it. + maxAlertTargetURI = 300 + + // maxAlertLength clamps the rendered alert. 4096 characters is the smallest + // per-message ceiling among the channels this text is delivered over + // (Telegram's sendMessage limit); the margin absorbs the truncation marker. + // Clamping in the domain rather than in a transport means every present and + // future channel inherits the guarantee. + maxAlertLength = 4000 + + // truncationMarker is appended wherever text was cut, so a reader never + // mistakes a shortened value for the whole one. + truncationMarker = "…(truncated)" +) + +// urgentReasons are the categories that carry legal exposure and a response +// clock. They are flagged in the alert text so a genuine emergency is visually +// distinct from a spam report in a phone notification. +var urgentReasons = map[Reason]bool{ + ReasonCSAM: true, + ReasonDoxing: true, + ReasonIllegal: true, +} + +// ParseAlertReasons converts operator-supplied reason names into the domain +// vocabulary, rejecting anything that is not a valid category. +// +// An unrecognised entry is an error rather than a silently dropped filter: a +// typo like "csam " or "CSAM_URGENT" that quietly narrowed the alert set to +// nothing would restore exactly the silence this feature removes. +// +// An empty input returns nil, which NewMessageNotifier reads as "alert on +// every reason". +func ParseAlertReasons(names []string) ([]Reason, error) { + var reasons []Reason + for _, name := range names { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + continue + } + if !IsValidReason(trimmed) { + return nil, fmt.Errorf("%w: %q is not one of csam, doxing, harassment, spam, illegal, other", + ErrUnknownAlertReason, trimmed) + } + reasons = append(reasons, Reason(trimmed)) + } + return reasons, nil +} + +// messageNotifier formats reports as plain text and hands them to a sender. +type messageNotifier struct { + sender MessageSender + + // reasons restricts which categories raise an alert. Nil or empty means + // every reason alerts. + reasons map[Reason]bool +} + +// NewMessageNotifier builds a Notifier that renders reports as plain text and +// delivers them through sender. +// +// reasons filters which categories alert; passing nil alerts on every reason. +// The filter exists because alert fatigue is the standard way systems like +// this die — an operator who has learned to swipe away spam reports will swipe +// away a CSAM report too — but it defaults to open on purpose: there is no +// admin read endpoint yet, so a filtered-out report is one nobody ever sees. +func NewMessageNotifier(sender MessageSender, reasons []Reason) Notifier { + notifier := &messageNotifier{sender: sender} + if len(reasons) > 0 { + notifier.reasons = make(map[Reason]bool, len(reasons)) + for _, reason := range reasons { + notifier.reasons[reason] = true + } + } + return notifier +} + +// NotifyNewReport sends the alert for report, unless its reason is filtered out. +func (n *messageNotifier) NotifyNewReport(ctx context.Context, report *Report) error { + if report == nil { + return ErrNilReport + } + if n.sender == nil { + return ErrNoMessageSender + } + if len(n.reasons) > 0 && !n.reasons[report.Reason] { + return nil + } + return n.sender.SendMessage(ctx, FormatReportAlert(report)) +} + +// FormatReportAlert renders a report as the plain-text body of an operator +// alert. +// +// The reporter's DID and the explanation text are deliberately withheld. +// Alerts travel through third-party infrastructure (Telegram's servers, and +// whatever mail provider is added later), and two things must not go there: +// the explanation, which for a CSAM or doxing report quotes the very content +// being reported, and the reporter's identity, which links a real person to an +// accusation. Both are one indexed lookup away for an operator who needs them, +// which is the point of leading with the report ID. +func FormatReportAlert(report *Report) string { + var text strings.Builder + + if urgentReasons[report.Reason] { + text.WriteString("\U0001F6A8 URGENT — new Coves report\n\n") + } else { + text.WriteString("New Coves report\n\n") + } + + fmt.Fprintf(&text, "Report: #%d\n", report.ID) + fmt.Fprintf(&text, "Reason: %s\n", report.Reason) + fmt.Fprintf(&text, "Target: %s\n", report.TargetType) + fmt.Fprintf(&text, "URI: %s\n", truncateRunes(report.TargetURI, maxAlertTargetURI)) + fmt.Fprintf(&text, "Filed: %s\n", report.CreatedAt.UTC().Format(time.RFC3339)) + + // TODO(admin-read-endpoints): until listReports/resolveReport exist, this + // query *is* the runbook — an alert an operator cannot act on is only + // marginally better than no alert. Replace it with a deep link once they + // land. Note SELECT * returns the two fields this alert withholds, so it is + // an escape hatch for someone at a psql prompt, not for the chat. + text.WriteString("\nReporter and explanation withheld from this alert.\n") + fmt.Fprintf(&text, "Read them with:\n SELECT * FROM admin_reports WHERE id = %d;", report.ID) + + // Clamp last: every field above is bounded, but the ceiling is a property of + // the whole message, and a future field must not be able to breach it. + return truncateRunes(text.String(), maxAlertLength) +} + +// truncateRunes shortens s to at most limit runes, marking that it was cut. +// +// Runes rather than bytes: slicing a multi-byte sequence mid-character yields +// invalid UTF-8, which delivery channels reject — turning a too-long alert into +// a rejected one, which is the failure this is here to prevent. +func truncateRunes(s string, limit int) string { + runes := []rune(s) + if len(runes) <= limit { + return s + } + return string(runes[:limit]) + truncationMarker +} diff --git a/internal/core/adminreports/notifier_test.go b/internal/core/adminreports/notifier_test.go new file mode 100644 index 0000000..fd15240 --- /dev/null +++ b/internal/core/adminreports/notifier_test.go @@ -0,0 +1,310 @@ +package adminreports + +import ( + "context" + "errors" + "strings" + "testing" + "time" + "unicode/utf8" +) + +// stubSender records what a notifier hands to its transport. +type stubSender struct { + sendFunc func(ctx context.Context, text string) error + messages []string +} + +func (s *stubSender) SendMessage(ctx context.Context, text string) error { + s.messages = append(s.messages, text) + if s.sendFunc != nil { + return s.sendFunc(ctx, text) + } + return nil +} + +// testReport builds a report with every field populated, so a test asserting +// that something is *absent* from an alert cannot pass by accident. +func testReport(reason Reason) *Report { + return &Report{ + ID: 47, + ReporterDID: "did:plc:reporter7890", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + TargetType: TargetTypePost, + Reason: reason, + Explanation: "the explanation text quoting the reported content", + Status: StatusOpen, + CreatedAt: time.Date(2026, 8, 1, 14, 22, 31, 0, time.UTC), + } +} + +func TestNotifyNewReport_SendsAlert(t *testing.T) { + sender := &stubSender{} + notifier := NewMessageNotifier(sender, nil) + report := testReport(ReasonSpam) + + if err := notifier.NotifyNewReport(context.Background(), report); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if len(sender.messages) != 1 { + t.Fatalf("expected exactly 1 message, got %d", len(sender.messages)) + } + + // Assert on what actually reaches the transport, not just that something + // did. The privacy guarantee belongs to the notifier: checking only + // FormatReportAlert leaves `SendMessage(ctx, alert + report.Explanation)` + // passing every test in this file. + if got := sender.messages[0]; got != FormatReportAlert(report) { + t.Errorf("notifier sent something other than the formatted alert:\n%s", got) + } +} + +// The boundary version of TestFormatReportAlert_WithholdsReporterAndExplanation: +// what the transport receives is what leaves our infrastructure. +func TestNotifyNewReport_SentMessageWithholdsReporterAndExplanation(t *testing.T) { + for _, reason := range ValidReasons() { + t.Run(string(reason), func(t *testing.T) { + sender := &stubSender{} + report := testReport(reason) + + if err := NewMessageNotifier(sender, nil).NotifyNewReport(context.Background(), report); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if len(sender.messages) != 1 { + t.Fatalf("expected exactly 1 message, got %d", len(sender.messages)) + } + + sent := sender.messages[0] + if strings.Contains(sent, report.ReporterDID) { + t.Errorf("message sent to the transport leaked the reporter DID:\n%s", sent) + } + if strings.Contains(sent, report.Explanation) { + t.Errorf("message sent to the transport leaked the explanation:\n%s", sent) + } + }) + } +} + +// A reporter must not be able to suppress the alert about their own report by +// submitting an oversized (but pattern-valid) target URI that pushes the +// message past the delivery channel's size limit. +func TestFormatReportAlert_BoundsOversizedTargetURI(t *testing.T) { + report := testReport(ReasonCSAM) + report.TargetURI = "at://did:plc:" + strings.Repeat("a", 9000) + "/social.coves.post/abc123" + + alert := FormatReportAlert(report) + + if runes := len([]rune(alert)); runes > maxAlertLength { + t.Errorf("alert is %d runes, want <= %d", runes, maxAlertLength) + } + if !strings.Contains(alert, truncationMarker) { + t.Errorf("an oversized URI must be marked as truncated:\n%s", alert) + } + // Truncation must not cost the operator the report ID — it is the only + // handle they have for finding the full record. + if !strings.Contains(alert, "#47") { + t.Errorf("truncation dropped the report ID:\n%s", alert) + } +} + +func TestTruncateRunes(t *testing.T) { + tests := []struct { + name string + input string + limit int + want string + }{ + {"under the limit is untouched", "short", 10, "short"}, + {"at the limit is untouched", "exactly10!", 10, "exactly10!"}, + {"over the limit is marked", "abcdefghijk", 10, "abcdefghij" + truncationMarker}, + {"empty stays empty", "", 10, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := truncateRunes(tt.input, tt.limit); got != tt.want { + t.Errorf("truncateRunes(%q, %d) = %q, want %q", tt.input, tt.limit, got, tt.want) + } + }) + } + + // Cutting mid-sequence would emit invalid UTF-8, which channels reject — + // turning a too-long alert into a rejected one, the failure this prevents. + t.Run("cuts on rune boundaries", func(t *testing.T) { + got := truncateRunes(strings.Repeat("é", 20), 5) + if !utf8.ValidString(got) { + t.Errorf("truncation produced invalid UTF-8: %q", got) + } + if want := strings.Repeat("é", 5) + truncationMarker; got != want { + t.Errorf("got %q, want %q", got, want) + } + }) +} + +// The alert must never carry the reporter's identity or the explanation text: +// both travel through third-party infrastructure, and the explanation quotes +// the very content being reported. +func TestFormatReportAlert_WithholdsReporterAndExplanation(t *testing.T) { + report := testReport(ReasonCSAM) + alert := FormatReportAlert(report) + + if strings.Contains(alert, report.ReporterDID) { + t.Errorf("alert leaked the reporter DID:\n%s", alert) + } + if strings.Contains(alert, report.Explanation) { + t.Errorf("alert leaked the explanation text:\n%s", alert) + } +} + +func TestFormatReportAlert_CarriesActionableFields(t *testing.T) { + report := testReport(ReasonDoxing) + alert := FormatReportAlert(report) + + for _, want := range []string{ + "#47", + string(ReasonDoxing), + string(TargetTypePost), + report.TargetURI, + "2026-08-01T14:22:31Z", + } { + if !strings.Contains(alert, want) { + t.Errorf("alert missing %q:\n%s", want, alert) + } + } +} + +// Urgency has to be visible in the notification preview on a phone, or the +// distinction between a spam report and a CSAM report is lost at the moment it +// matters most. +func TestFormatReportAlert_FlagsUrgentReasons(t *testing.T) { + tests := []struct { + reason Reason + wantUrgent bool + }{ + {ReasonCSAM, true}, + {ReasonDoxing, true}, + {ReasonIllegal, true}, + {ReasonSpam, false}, + {ReasonHarassment, false}, + {ReasonOther, false}, + } + + for _, tt := range tests { + t.Run(string(tt.reason), func(t *testing.T) { + alert := FormatReportAlert(testReport(tt.reason)) + gotUrgent := strings.Contains(alert, "URGENT") + + if gotUrgent != tt.wantUrgent { + t.Errorf("reason %q: urgent flag = %v, want %v:\n%s", + tt.reason, gotUrgent, tt.wantUrgent, alert) + } + }) + } +} + +func TestNotifyNewReport_FiltersByReason(t *testing.T) { + sender := &stubSender{} + notifier := NewMessageNotifier(sender, []Reason{ReasonCSAM, ReasonIllegal}) + + if err := notifier.NotifyNewReport(context.Background(), testReport(ReasonSpam)); err != nil { + t.Fatalf("a filtered reason must not error, got: %v", err) + } + if len(sender.messages) != 0 { + t.Fatalf("expected spam to be filtered out, got %d message(s)", len(sender.messages)) + } + + if err := notifier.NotifyNewReport(context.Background(), testReport(ReasonCSAM)); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if len(sender.messages) != 1 { + t.Fatalf("expected csam to pass the filter, got %d message(s)", len(sender.messages)) + } +} + +// An empty filter must mean "everything". Reading it as "nothing" would leave +// the default configuration silently delivering no alerts at all. +func TestNotifyNewReport_EmptyFilterAlertsOnEveryReason(t *testing.T) { + sender := &stubSender{} + notifier := NewMessageNotifier(sender, nil) + + for _, reason := range ValidReasons() { + if err := notifier.NotifyNewReport(context.Background(), testReport(reason)); err != nil { + t.Fatalf("reason %q: expected no error, got: %v", reason, err) + } + } + + if len(sender.messages) != len(ValidReasons()) { + t.Errorf("expected %d alerts, got %d", len(ValidReasons()), len(sender.messages)) + } +} + +func TestNotifyNewReport_PropagatesSenderError(t *testing.T) { + sendErr := errors.New("transport unavailable") + sender := &stubSender{ + sendFunc: func(ctx context.Context, text string) error { return sendErr }, + } + notifier := NewMessageNotifier(sender, nil) + + err := notifier.NotifyNewReport(context.Background(), testReport(ReasonCSAM)) + if !errors.Is(err, sendErr) { + t.Fatalf("expected the sender's error to surface, got: %v", err) + } +} + +func TestNotifyNewReport_RejectsNilInputs(t *testing.T) { + t.Run("nil report", func(t *testing.T) { + notifier := NewMessageNotifier(&stubSender{}, nil) + if err := notifier.NotifyNewReport(context.Background(), nil); !errors.Is(err, ErrNilReport) { + t.Fatalf("expected ErrNilReport, got: %v", err) + } + }) + + t.Run("nil sender", func(t *testing.T) { + notifier := NewMessageNotifier(nil, nil) + err := notifier.NotifyNewReport(context.Background(), testReport(ReasonCSAM)) + if !errors.Is(err, ErrNoMessageSender) { + t.Fatalf("expected ErrNoMessageSender, got: %v", err) + } + }) +} + +func TestParseAlertReasons(t *testing.T) { + t.Run("valid names", func(t *testing.T) { + reasons, err := ParseAlertReasons([]string{"csam", " doxing ", "illegal"}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + want := []Reason{ReasonCSAM, ReasonDoxing, ReasonIllegal} + if len(reasons) != len(want) { + t.Fatalf("expected %d reasons, got %d: %v", len(want), len(reasons), reasons) + } + for i, reason := range reasons { + if reason != want[i] { + t.Errorf("reason %d = %q, want %q", i, reason, want[i]) + } + } + }) + + t.Run("empty input means every reason", func(t *testing.T) { + reasons, err := ParseAlertReasons(nil) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if reasons != nil { + t.Errorf("expected nil (alert on everything), got: %v", reasons) + } + }) + + // A typo must fail startup. Silently dropping it would narrow the alert set + // without saying so — restoring the silence this feature exists to remove. + t.Run("unknown name is rejected", func(t *testing.T) { + _, err := ParseAlertReasons([]string{"csam", "urgent"}) + if !errors.Is(err, ErrUnknownAlertReason) { + t.Fatalf("expected ErrUnknownAlertReason, got: %v", err) + } + if !strings.Contains(err.Error(), "urgent") { + t.Errorf("error should name the offending value, got: %v", err) + } + }) +} diff --git a/internal/core/adminreports/service.go b/internal/core/adminreports/service.go index 11ec683..fd70d20 100644 --- a/internal/core/adminreports/service.go +++ b/internal/core/adminreports/service.go @@ -2,18 +2,63 @@ package adminreports import ( "context" + "log/slog" + "time" +) + +const ( + // alertTimeout is the ceiling on how long report submission will wait for + // the alert channel. A channel normally imposes its own, shorter timeout + // (Telegram's default is 5s); this is the backstop that keeps a misbehaving + // or unconfigured transport from pinning the request forever. A channel + // timeout above this value is capped by it — see TELEGRAM_TIMEOUT_SECONDS. + alertTimeout = 10 * time.Second + + // maxConcurrentAlerts bounds how many submissions may be parked on the + // alert channel at once. + // + // This bound has to exist here because nothing upstream provides one: the + // route's rate limiter keys on client IP (middleware.GetClientIP), so N + // source addresses buy N independent budgets, and delivery is synchronous. + // Without a cap, a slow or unreachable channel converts report submission + // into an unbounded supply of request goroutines each parked for up to + // alertTimeout. Saturation drops the alert loudly rather than queueing, + // because a queued alert delivered minutes late is worth less than the log + // line saying it was dropped. + maxConcurrentAlerts = 4 ) // service implements the Service interface for admin reports type service struct { - repo Repository + repo Repository + notifier Notifier + + // alertSlots is a counting semaphore enforcing maxConcurrentAlerts. + alertSlots chan struct{} +} + +// ServiceOption configures optional service dependencies. Options keep the +// constructor stable as new optional collaborators are added. +type ServiceOption func(*service) + +// WithNotifier raises an operator alert for every accepted report. Without it +// the service is storage-only: reports are recorded and nobody is told. +func WithNotifier(notifier Notifier) ServiceOption { + return func(s *service) { + s.notifier = notifier + } } // NewService creates a new admin reports service -func NewService(repo Repository) Service { - return &service{ - repo: repo, +func NewService(repo Repository, opts ...ServiceOption) Service { + s := &service{ + repo: repo, + alertSlots: make(chan struct{}, maxConcurrentAlerts), } + for _, opt := range opts { + opt(s) + } + return s } // SubmitReport validates the report request and creates a new report @@ -30,7 +75,71 @@ func (s *service) SubmitReport(ctx context.Context, req SubmitReportRequest) (*S return nil, err } + s.raiseAlert(ctx, report) + return &SubmitReportResult{ ReportID: report.ID, }, nil } + +// raiseAlert tells an operator that a report has been filed. +// +// Two decisions worth stating, because both look like bugs from the outside: +// +// Delivery is synchronous. The obvious alternative — hand it to a goroutine so +// the reporter never waits — drops the alert silently whenever the process +// exits before it lands, and losing a CSAM alert to a routine deploy is a +// worse outcome than making one reporter wait a few seconds. maxConcurrentAlerts +// is what keeps that choice from being unbounded. +// +// Nothing here can fail the submission. The row is already committed: answering +// an error would tell reporters their report failed when it did not, and invite +// a retry that files a duplicate. That has to hold for panics too, not just +// errors — a panicking notifier would otherwise reach chi's Recoverer and turn +// a stored report into a 500, producing exactly the duplicate this avoids. +func (s *service) raiseAlert(ctx context.Context, report *Report) { + if s.notifier == nil { + return + } + + defer func() { + if recovered := recover(); recovered != nil { + slog.Error("admin report stored but operator alert panicked", + "report_id", report.ID, + "reason", report.Reason, + "panic", recovered, + ) + } + }() + + select { + case s.alertSlots <- struct{}{}: + defer func() { <-s.alertSlots }() + default: + slog.Error("admin report stored but operator alert dropped: alert channel saturated", + "report_id", report.ID, + "reason", report.Reason, + "target_uri", report.TargetURI, + "in_flight", maxConcurrentAlerts, + ) + return + } + + // Detach from the request's cancellation. The report is already stored, and + // a reporter who closes the app the instant they hit submit must not also + // cancel the alert about them doing it. Context values (trace IDs) survive; + // only the cancellation signal is dropped. + alertCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), alertTimeout) + defer cancel() + + if err := s.notifier.NotifyNewReport(alertCtx, report); err != nil { + // The report ID is the recovery path: it is enough to find the full + // record in Postgres once someone reads this line. + slog.Error("admin report stored but operator alert failed", + "report_id", report.ID, + "reason", report.Reason, + "target_uri", report.TargetURI, + "error", err, + ) + } +} diff --git a/internal/core/adminreports/service_alert_test.go b/internal/core/adminreports/service_alert_test.go new file mode 100644 index 0000000..3522753 --- /dev/null +++ b/internal/core/adminreports/service_alert_test.go @@ -0,0 +1,284 @@ +package adminreports + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +// mockNotifier records the alerts a service raises. +// +// Guarded by a mutex because the concurrency-bound test drives it from several +// goroutines at once. +type mockNotifier struct { + notifyFunc func(ctx context.Context, report *Report) error + + mu sync.Mutex + notified []*Report + // contextErrs captures ctx.Err() as seen inside the notifier, so a test can + // prove the alert is not riding the request's cancellation. + contextErrs []error +} + +func (m *mockNotifier) NotifyNewReport(ctx context.Context, report *Report) error { + m.mu.Lock() + m.notified = append(m.notified, report) + m.contextErrs = append(m.contextErrs, ctx.Err()) + m.mu.Unlock() + + if m.notifyFunc != nil { + return m.notifyFunc(ctx, report) + } + return nil +} + +// alerts returns a snapshot of the reports the notifier was handed. +func (m *mockNotifier) alerts() []*Report { + m.mu.Lock() + defer m.mu.Unlock() + return append([]*Report(nil), m.notified...) +} + +// countingRepo is a Repository safe to drive from many goroutines at once, +// which the shared mockRepository is not. +type countingRepo struct { + mu sync.Mutex + created int +} + +func (r *countingRepo) Create(ctx context.Context, report *Report) error { + r.mu.Lock() + defer r.mu.Unlock() + r.created++ + report.ID = int64(r.created) + return nil +} + +func (r *countingRepo) ListByStatus(ctx context.Context, status string, limit, offset int) ([]*Report, error) { + return nil, nil +} + +func (r *countingRepo) UpdateStatus(ctx context.Context, id int64, status, resolvedBy, notes string) error { + return nil +} + +func (r *countingRepo) count() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.created +} + +func validSubmitRequest(reason string) SubmitReportRequest { + return SubmitReportRequest{ + ReporterDID: "did:plc:reporter7890", + TargetURI: "at://did:plc:author123/social.coves.post/abc123", + Reason: reason, + Explanation: "explanation text", + } +} + +func TestSubmitReport_RaisesAlert(t *testing.T) { + repo := &mockRepository{} + notifier := &mockNotifier{} + svc := NewService(repo, WithNotifier(notifier)) + + result, err := svc.SubmitReport(context.Background(), validSubmitRequest("csam")) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if len(notifier.notified) != 1 { + t.Fatalf("expected exactly 1 alert, got %d", len(notifier.notified)) + } + + // The alert must carry the stored report, not the pre-insert one: the ID is + // the only handle an operator has for finding the record. + if got := notifier.notified[0].ID; got != result.ReportID { + t.Errorf("alert carried report ID %d, want %d", got, result.ReportID) + } + if got := notifier.notified[0].Reason; got != ReasonCSAM { + t.Errorf("alert carried reason %q, want %q", got, ReasonCSAM) + } +} + +// The row is committed before the alert is attempted. A failed alert must not +// be reported to the user as a failed report — that invites a retry, and the +// retry files a duplicate. +func TestSubmitReport_AlertFailureDoesNotFailSubmission(t *testing.T) { + repo := &mockRepository{} + notifier := &mockNotifier{ + notifyFunc: func(ctx context.Context, report *Report) error { + return errors.New("telegram unreachable") + }, + } + svc := NewService(repo, WithNotifier(notifier)) + + result, err := svc.SubmitReport(context.Background(), validSubmitRequest("csam")) + if err != nil { + t.Fatalf("alert failure must not fail submission, got: %v", err) + } + if result == nil || result.ReportID == 0 { + t.Fatal("expected a usable report ID despite the alert failure") + } + if len(repo.createdReports) != 1 { + t.Fatalf("expected the report to be stored, got %d row(s)", len(repo.createdReports)) + } +} + +// The mobile client fires this request and the user may close the app +// immediately. If the alert inherited the request context it would be +// cancelled before it ever left the process. +func TestSubmitReport_AlertSurvivesRequestCancellation(t *testing.T) { + repo := &mockRepository{} + notifier := &mockNotifier{} + svc := NewService(repo, WithNotifier(notifier)) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := svc.SubmitReport(ctx, validSubmitRequest("csam")); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if len(notifier.notified) != 1 { + t.Fatalf("expected the alert to be raised, got %d", len(notifier.notified)) + } + if err := notifier.contextErrs[0]; err != nil { + t.Errorf("alert context was already cancelled (%v); it must be detached from the request", err) + } +} + +func TestSubmitReport_NoAlertWhenValidationFails(t *testing.T) { + repo := &mockRepository{} + notifier := &mockNotifier{} + svc := NewService(repo, WithNotifier(notifier)) + + if _, err := svc.SubmitReport(context.Background(), validSubmitRequest("not-a-reason")); err == nil { + t.Fatal("expected a validation error") + } + + if len(notifier.notified) != 0 { + t.Errorf("expected no alert for a rejected report, got %d", len(notifier.notified)) + } +} + +func TestSubmitReport_NoAlertWhenStorageFails(t *testing.T) { + repo := &mockRepository{ + createFunc: func(ctx context.Context, report *Report) error { + return errors.New("database unavailable") + }, + } + notifier := &mockNotifier{} + svc := NewService(repo, WithNotifier(notifier)) + + if _, err := svc.SubmitReport(context.Background(), validSubmitRequest("csam")); err == nil { + t.Fatal("expected a storage error") + } + + // Alerting on an unstored report would send an operator to a row that does + // not exist. + if len(notifier.notified) != 0 { + t.Errorf("expected no alert when storage failed, got %d", len(notifier.notified)) + } +} + +// The contract is "an alert failure is never a report failure" — which has to +// cover panics, not just errors. Without recovery a panicking notifier reaches +// chi's Recoverer and turns a committed report into a 500, so the reporter +// retries and files the duplicate this design exists to avoid. +func TestSubmitReport_AlertPanicDoesNotFailSubmission(t *testing.T) { + repo := &mockRepository{} + notifier := &mockNotifier{ + notifyFunc: func(ctx context.Context, report *Report) error { + panic("notifier exploded") + }, + } + svc := NewService(repo, WithNotifier(notifier)) + + result, err := svc.SubmitReport(context.Background(), validSubmitRequest("csam")) + if err != nil { + t.Fatalf("a panicking notifier must not fail submission, got: %v", err) + } + if result == nil || result.ReportID == 0 { + t.Fatal("expected a usable report ID despite the panic") + } + if len(repo.createdReports) != 1 { + t.Fatalf("expected the report to be stored, got %d row(s)", len(repo.createdReports)) + } +} + +// Delivery is synchronous and the route's rate limiter keys on client IP, so +// without this bound a slow channel converts report submission into an +// unbounded supply of parked request goroutines. +func TestSubmitReport_BoundsConcurrentAlerts(t *testing.T) { + release := make(chan struct{}) + entered := make(chan struct{}, maxConcurrentAlerts+2) + + repo := &countingRepo{} + notifier := &mockNotifier{ + notifyFunc: func(ctx context.Context, report *Report) error { + entered <- struct{}{} + <-release + return nil + }, + } + svc := NewService(repo, WithNotifier(notifier)) + + // Fill every slot and leave them parked. + var wg sync.WaitGroup + for range maxConcurrentAlerts { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := svc.SubmitReport(context.Background(), validSubmitRequest("csam")); err != nil { + t.Errorf("expected no error, got: %v", err) + } + }() + } + for range maxConcurrentAlerts { + <-entered + } + + // One more must not block: it is dropped rather than queued, and the report + // still succeeds. + done := make(chan struct{}) + go func() { + defer close(done) + if _, err := svc.SubmitReport(context.Background(), validSubmitRequest("csam")); err != nil { + t.Errorf("expected no error on the saturated path, got: %v", err) + } + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("submission blocked while the alert channel was saturated; it must drop, not queue") + } + + close(release) + wg.Wait() + + if got := repo.count(); got != maxConcurrentAlerts+1 { + t.Errorf("expected all %d reports stored, got %d", maxConcurrentAlerts+1, got) + } + if got := len(notifier.alerts()); got != maxConcurrentAlerts { + t.Errorf("expected %d alerts to reach the notifier (the last dropped), got %d", + maxConcurrentAlerts, got) + } +} + +// Alerting is opt-in; the default build must not require a notifier. +func TestSubmitReport_WithoutNotifier(t *testing.T) { + repo := &mockRepository{} + svc := NewService(repo) + + result, err := svc.SubmitReport(context.Background(), validSubmitRequest("csam")) + if err != nil { + t.Fatalf("expected no error without a notifier, got: %v", err) + } + if result.ReportID == 0 { + t.Error("expected a report ID") + } +} diff --git a/internal/notify/telegram/client.go b/internal/notify/telegram/client.go new file mode 100644 index 0000000..38cb29b --- /dev/null +++ b/internal/notify/telegram/client.go @@ -0,0 +1,198 @@ +package telegram + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +const ( + // maxResponseBytes caps how much of a Bot API response is read. Responses + // are small JSON objects; the limit stops a misbehaving or spoofed endpoint + // from feeding the process an unbounded body. + maxResponseBytes = 64 * 1024 + + // redactedToken replaces the bot token wherever it would otherwise be + // rendered into a string. + redactedToken = "[REDACTED]" + + // maxDescriptionLength caps how much of the Bot API's error description is + // carried into our error. The description is remote-controlled text headed + // straight for a log line, and the response budget allows up to + // maxResponseBytes of it. + maxDescriptionLength = 256 +) + +// ErrEmptyMessage indicates SendMessage was called with no text. The Bot API +// rejects empty messages, so this is caught before spending a request. +var ErrEmptyMessage = errors.New("cannot send an empty Telegram message") + +// Client sends plain-text messages to a fixed Telegram chat. +// +// It satisfies adminreports.MessageSender without importing it — the domain +// defines the interface it needs, and this package just happens to fit. +type Client struct { + botToken string + chatID string + baseURL string + httpClient *http.Client +} + +// NewClient builds a Client from cfg. +// +// A disabled config is an error rather than a no-op client: "constructed but +// inert" is a state that reads as working at every call site, and callers +// should branch on cfg.Enabled where the decision is visible. +func NewClient(cfg Config) (*Client, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + if !cfg.Enabled { + return nil, ErrDisabled + } + + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = defaultBaseURL + } + + return &Client{ + botToken: cfg.BotToken, + chatID: cfg.ChatID, + baseURL: strings.TrimSuffix(baseURL, "/"), + httpClient: &http.Client{Timeout: cfg.Timeout}, + }, nil +} + +// sendMessagePayload is the Bot API sendMessage request body. +type sendMessagePayload struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + + // DisableWebPagePreview stops Telegram from fetching and rendering any URL + // in the message. This is a safety requirement, not a cosmetic one: alert + // text carries the URI of reported content, and Telegram resolving that + // link server-side would pull the reported material into the chat — the + // precise outcome to avoid when the report is about CSAM. + DisableWebPagePreview bool `json:"disable_web_page_preview"` +} + +// apiResponse is the envelope every Bot API method returns. +type apiResponse struct { + OK bool `json:"ok"` + ErrorCode int `json:"error_code"` + Description string `json:"description"` +} + +// SendMessage delivers text to the configured chat. +// +// No parse_mode is set, so Telegram treats the body as literal text. That is +// deliberate: alert text embeds a caller-supplied URI, and enabling Markdown or +// HTML would turn every escaping mistake into either a rejected alert or an +// injected link. +func (c *Client) SendMessage(ctx context.Context, text string) error { + if strings.TrimSpace(text) == "" { + return ErrEmptyMessage + } + + body, err := json.Marshal(sendMessagePayload{ + ChatID: c.chatID, + Text: text, + DisableWebPagePreview: true, + }) + if err != nil { + return fmt.Errorf("telegram: encoding sendMessage body: %w", err) + } + + // The token sits in the path, which is what the Bot API requires and what + // makes every error out of this call a redaction hazard. + endpoint := fmt.Sprintf("%s/bot%s/sendMessage", c.baseURL, c.botToken) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("telegram: building sendMessage request: %w", c.scrub(err)) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("telegram: sendMessage request failed: %w", c.scrub(err)) + } + defer resp.Body.Close() + + // Read before inspecting the status: the body carries the Bot API's own + // error description, which is far more useful than the code alone. Draining + // it also lets the connection be reused. + payload, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return fmt.Errorf("telegram: reading sendMessage response (status %d): %w", + resp.StatusCode, c.scrub(err)) + } + + var api apiResponse + if err := json.Unmarshal(payload, &api); err != nil { + // An unparseable body means this is not the Bot API — a proxy error + // page, say. The body itself is not echoed: it is untrusted content of + // unknown size headed for the logs. + return fmt.Errorf("telegram: sendMessage returned status %d with an unparseable body", + resp.StatusCode) + } + + // Both conditions, not just api.OK. The real Bot API pairs 200 with ok:true, + // but this response crossed an untrusted network path — a proxy, CDN, or WAF + // that synthesizes or replays a JSON envelope on a 5xx would otherwise make + // a failed send return nil, which is the one failure shape that produces no + // log line anywhere. + if !api.OK || resp.StatusCode < 200 || resp.StatusCode >= 300 { + // The description is the actionable part ("chat not found" tells an + // operator their chat ID is wrong), but it is remote-controlled text, so + // it gets the same redaction every other error path here gets, plus a + // length cap. + return fmt.Errorf("telegram: sendMessage rejected (status %d, error_code %d): %s", + resp.StatusCode, api.ErrorCode, c.safeDescription(api.Description)) + } + + return nil +} + +// safeDescription prepares a Bot API error description for inclusion in an +// error: token redacted, length capped, newlines flattened so a hostile +// description cannot forge extra log lines. +func (c *Client) safeDescription(description string) string { + safe := c.redact(strings.ReplaceAll(strings.ReplaceAll(description, "\n", " "), "\r", " ")) + + runes := []rune(safe) + if len(runes) > maxDescriptionLength { + safe = string(runes[:maxDescriptionLength]) + "…" + } + return safe +} + +// scrub removes the bot token from an error before it can be returned. +// +// net/http embeds the full request URL in *url.Error, and the token is part of +// that URL — so wrapping a transport error verbatim publishes the credential to +// wherever logs go. The error chain is flattened to a plain message rather than +// wrapped, because errors.As on the original would hand a caller back the very +// *url.Error whose URL field still holds the token. Losing errors.Is on a +// network failure costs little; leaking a bot token costs a takeover of the +// alert channel. +func (c *Client) scrub(err error) error { + if err == nil { + return nil + } + return errors.New(c.redact(err.Error())) +} + +// redact replaces every occurrence of the bot token in s. +func (c *Client) redact(s string) string { + if c.botToken == "" { + return s + } + return strings.ReplaceAll(s, c.botToken, redactedToken) +} diff --git a/internal/notify/telegram/client_test.go b/internal/notify/telegram/client_test.go new file mode 100644 index 0000000..d2efcc0 --- /dev/null +++ b/internal/notify/telegram/client_test.go @@ -0,0 +1,394 @@ +package telegram + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" +) + +const testToken = "123456789:AAExampleBotTokenValueNotReal" + +// newTestClient wires a Client to handler and returns both. +func newTestClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + client, err := NewClient(Config{ + Enabled: true, + BotToken: testToken, + ChatID: "-1001234567890", + Timeout: 5 * time.Second, + BaseURL: server.URL, + }) + if err != nil { + t.Fatalf("building client: %v", err) + } + return client +} + +// okResponse writes the Bot API's success envelope. +func okResponse(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`) +} + +// Asserted against raw wire keys rather than sendMessagePayload: decoding into +// the production struct shares its tags with the encoder, so a renamed tag +// would satisfy the test while sending a key Telegram ignores. +func TestSendMessage_PostsToBotAPI(t *testing.T) { + var gotPath string + var raw map[string]any + var gotContentType string + + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotContentType = r.Header.Get("Content-Type") + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + t.Errorf("decoding request body: %v", err) + } + okResponse(w) + }) + + if err := client.SendMessage(context.Background(), "alert text"); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if want := "/bot" + testToken + "/sendMessage"; gotPath != want { + t.Errorf("path = %q, want %q", gotPath, want) + } + if gotContentType != "application/json" { + t.Errorf("Content-Type = %q, want application/json", gotContentType) + } + if got, ok := raw["text"]; !ok || got != "alert text" { + t.Errorf("wire key text = %v, want %q", got, "alert text") + } + if got, ok := raw["chat_id"]; !ok || got != "-1001234567890" { + t.Errorf("wire key chat_id = %v, want %q", got, "-1001234567890") + } +} + +// Success requires a 2xx AND ok:true. A proxy, CDN, or WAF between us and +// Telegram that synthesizes or replays a JSON envelope on an error response +// would otherwise make a failed send return nil — the one failure shape that +// produces no log line anywhere. +func TestSendMessage_RejectsNon2xxWithOKBody(t *testing.T) { + tests := []struct { + name string + status int + body string + }{ + {"gateway error with an ok envelope", http.StatusBadGateway, `{"ok":true,"result":{"message_id":1}}`}, + {"service unavailable with an ok envelope", http.StatusServiceUnavailable, `{"ok":true}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.status) + _, _ = io.WriteString(w, tt.body) + }) + + err := client.SendMessage(context.Background(), "alert text") + if err == nil { + t.Fatalf("status %d with ok:true must not count as delivered", tt.status) + } + if !strings.Contains(err.Error(), strconv.Itoa(tt.status)) { + t.Errorf("error should carry the status code, got: %v", err) + } + }) + } +} + +// The API description is remote-controlled text headed straight for a log line. +func TestSendMessage_SanitizesAPIDescription(t *testing.T) { + t.Run("redacts the bot token", func(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(apiResponse{ + OK: false, + ErrorCode: 400, + Description: "bad request for /bot" + testToken + "/sendMessage", + }) + }) + + err := client.SendMessage(context.Background(), "alert text") + if err == nil { + t.Fatal("expected an error") + } + if strings.Contains(err.Error(), testToken) { + t.Errorf("description path leaked the bot token: %v", err) + } + }) + + t.Run("caps the length and flattens newlines", func(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(apiResponse{ + OK: false, + ErrorCode: 400, + Description: strings.Repeat("A", 5000) + "\nforged log line", + }) + }) + + err := client.SendMessage(context.Background(), "alert text") + if err == nil { + t.Fatal("expected an error") + } + if runes := len([]rune(err.Error())); runes > maxDescriptionLength+200 { + t.Errorf("error is %d runes; the description should be capped near %d", + runes, maxDescriptionLength) + } + // A newline in remote text must not be able to forge an extra log line. + if strings.Contains(err.Error(), "\n") { + t.Errorf("description newlines must be flattened, got: %q", err.Error()) + } + }) +} + +// Link previews must stay off: alert text carries the URI of reported content, +// and Telegram resolving it server-side would render the reported material into +// the chat. +// Asserted against the raw wire keys, deliberately: decoding into +// sendMessagePayload would share the struct tag with the encoder, so the check +// would pass even if the tag were renamed and Telegram silently began fetching +// reported URIs again. +func TestSendMessage_DisablesLinkPreview(t *testing.T) { + var raw map[string]any + + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + t.Errorf("decoding request body: %v", err) + } + okResponse(w) + }) + + if err := client.SendMessage(context.Background(), "at://did:plc:x/social.coves.post/y"); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if got, ok := raw["disable_web_page_preview"]; !ok || got != true { + t.Errorf("wire key disable_web_page_preview must be true so Telegram never "+ + "fetches reported URIs; got %v (present=%v) in payload %v", got, ok, raw) + } +} + +// No parse_mode means Telegram renders the body literally, so an alert +// containing Markdown or HTML metacharacters cannot be reinterpreted as markup. +func TestSendMessage_SendsPlainTextOnly(t *testing.T) { + var raw map[string]any + + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + t.Errorf("decoding request body: %v", err) + } + okResponse(w) + }) + + if err := client.SendMessage(context.Background(), "_underscores_ and "); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if _, present := raw["parse_mode"]; present { + t.Errorf("parse_mode must not be sent; got payload %v", raw) + } +} + +func TestSendMessage_ReturnsAPIError(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"ok":false,"error_code":400,"description":"chat not found"}`) + }) + + err := client.SendMessage(context.Background(), "alert text") + if err == nil { + t.Fatal("expected an error") + } + // The description is the actionable part — "chat not found" tells the + // operator their chat ID is wrong, which a bare 400 does not. + if !strings.Contains(err.Error(), "chat not found") { + t.Errorf("error should carry the API description, got: %v", err) + } +} + +func TestSendMessage_HandlesNonAPIResponse(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = io.WriteString(w, "502 Bad Gateway") + }) + + err := client.SendMessage(context.Background(), "alert text") + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), "502") { + t.Errorf("error should carry the status code, got: %v", err) + } + // The body is untrusted content of unknown size; it must not be echoed into + // the logs. + if strings.Contains(err.Error(), "") { + t.Errorf("error must not echo the response body, got: %v", err) + } +} + +// The bot token sits in the request path, so net/http embeds it in *url.Error. +// Every error this client returns is destined for a log line. +func TestSendMessage_NeverLeaksBotToken(t *testing.T) { + t.Run("transport failure", func(t *testing.T) { + // A server closed before the request guarantees a transport-level + // *url.Error carrying the full URL. + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + serverURL := server.URL + server.Close() + + client, err := NewClient(Config{ + Enabled: true, + BotToken: testToken, + ChatID: "-1001234567890", + Timeout: time.Second, + BaseURL: serverURL, + }) + if err != nil { + t.Fatalf("building client: %v", err) + } + + sendErr := client.SendMessage(context.Background(), "alert text") + if sendErr == nil { + t.Fatal("expected a transport error") + } + if strings.Contains(sendErr.Error(), testToken) { + t.Errorf("error leaked the bot token: %v", sendErr) + } + if !strings.Contains(sendErr.Error(), redactedToken) { + t.Errorf("expected the token to be redacted, got: %v", sendErr) + } + }) + + t.Run("request timeout", func(t *testing.T) { + // The handler parks until the test releases it, so the client's own + // timeout is what ends the request. Cleanups run last-registered-first, + // so registering the release *after* server.Close makes it fire first — + // which is what lets Close, whose job is to wait on in-flight handlers, + // return at all. + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + <-release + })) + t.Cleanup(server.Close) + t.Cleanup(func() { close(release) }) + + client, err := NewClient(Config{ + Enabled: true, + BotToken: testToken, + ChatID: "-1001234567890", + Timeout: 50 * time.Millisecond, + BaseURL: server.URL, + }) + if err != nil { + t.Fatalf("building client: %v", err) + } + + sendErr := client.SendMessage(context.Background(), "alert text") + if sendErr == nil { + t.Fatal("expected a timeout error") + } + if strings.Contains(sendErr.Error(), testToken) { + t.Errorf("error leaked the bot token: %v", sendErr) + } + }) +} + +// A context cancelled before the call must fail without ever reaching the wire. +func TestSendMessage_HonoursContextCancellation(t *testing.T) { + // Atomic because the counter is written on the server goroutine and read on + // the test goroutine. It is zero today only because the request never goes + // out — which is exactly the assertion, so the regression case is the one + // that would race. + var requests atomic.Int32 + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + okResponse(w) + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if err := client.SendMessage(ctx, "alert text"); err == nil { + t.Fatal("expected a cancellation error") + } + if got := requests.Load(); got != 0 { + t.Errorf("expected no request to be sent, got %d", got) + } +} + +func TestSendMessage_RejectsEmptyText(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("no request should be sent for empty text") + okResponse(w) + }) + + for _, text := range []string{"", " ", "\n\t"} { + if err := client.SendMessage(context.Background(), text); err != ErrEmptyMessage { + t.Errorf("text %q: expected ErrEmptyMessage, got: %v", text, err) + } + } +} + +func TestNewClient(t *testing.T) { + t.Run("rejects a disabled config", func(t *testing.T) { + _, err := NewClient(Config{Enabled: false}) + if err != ErrDisabled { + t.Fatalf("expected ErrDisabled, got: %v", err) + } + }) + + t.Run("rejects an invalid config", func(t *testing.T) { + _, err := NewClient(Config{Enabled: true, ChatID: "1", Timeout: time.Second}) + if err != ErrMissingBotToken { + t.Fatalf("expected ErrMissingBotToken, got: %v", err) + } + }) + + t.Run("defaults the base URL to the public API", func(t *testing.T) { + client, err := NewClient(Config{ + Enabled: true, + BotToken: testToken, + ChatID: "1", + Timeout: time.Second, + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if client.baseURL != defaultBaseURL { + t.Errorf("baseURL = %q, want %q", client.baseURL, defaultBaseURL) + } + }) + + t.Run("trims a trailing slash from the base URL", func(t *testing.T) { + client, err := NewClient(Config{ + Enabled: true, + BotToken: testToken, + ChatID: "1", + Timeout: time.Second, + BaseURL: "https://example.test/", + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if client.baseURL != "https://example.test" { + t.Errorf("baseURL = %q, want %q", client.baseURL, "https://example.test") + } + }) +} diff --git a/internal/notify/telegram/config.go b/internal/notify/telegram/config.go new file mode 100644 index 0000000..74e1870 --- /dev/null +++ b/internal/notify/telegram/config.go @@ -0,0 +1,173 @@ +// Package telegram delivers operator alerts to a Telegram chat via the Bot API. +// +// It exists because Coves is run by a very small team with no on-call rotation: +// an alert that only reaches a database table reaches nobody. Telegram was +// chosen over email for the alerting path because it has no deliverability +// surface — no SPF, no DKIM, no spam folder, no domain reputation to maintain — +// and it reaches a phone in seconds. Email remains the better channel for a +// durable, searchable record; this is the channel for "look at this now". +// +// The client is deliberately generic: SendMessage takes plain text and knows +// nothing about reports, so any domain needing to reach an operator can use it. +// +// Alerting is off unless TELEGRAM_ALERTS_ENABLED is set. Most operators running +// their own Coves instance will not want it, and a self-hosted deployment must +// not need a Telegram account to boot. +package telegram + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" +) + +const ( + // defaultTimeout bounds a single Bot API call. Report submission blocks on + // this, so it is short: an alert that has not landed in five seconds is + // better logged as failed than left holding the reporter's request. + defaultTimeout = 5 * time.Second + + // defaultBaseURL is the public Bot API origin. Tests override it. + defaultBaseURL = "https://api.telegram.org" +) + +// Configuration errors. These fail startup rather than degrading to "alerts +// off", because a silently disabled alerter is the exact fault this package +// was written to remove. +var ( + // ErrMissingBotToken indicates alerts are enabled with no bot token. + ErrMissingBotToken = errors.New("TELEGRAM_BOT_TOKEN is required when TELEGRAM_ALERTS_ENABLED is true") + + // ErrMissingChatID indicates alerts are enabled with no destination chat. + ErrMissingChatID = errors.New("TELEGRAM_CHAT_ID is required when TELEGRAM_ALERTS_ENABLED is true") + + // ErrInvalidTimeout indicates a non-positive request timeout. + ErrInvalidTimeout = errors.New("Telegram request timeout must be positive") + + // ErrDisabled indicates a client was requested from a disabled config. + ErrDisabled = errors.New("Telegram alerts are disabled") +) + +// lookup reads an environment variable, trimming surrounding whitespace. +// Docker Compose and .env files routinely leave trailing spaces, and a token +// that fails only because of one is a miserable thing to debug. +func lookup(key string) string { + return strings.TrimSpace(os.Getenv(key)) +} + +// Config holds the settings for the Telegram alert channel. +type Config struct { + // Enabled turns the alert channel on. When false every other field is + // ignored and no client is built. + Enabled bool + + // BotToken authenticates against the Bot API. It is a credential: it + // appears in the request path, so it must never reach a log or an error + // message. See Client.scrub. + BotToken string + + // ChatID is the destination chat. Accepts a numeric ID (including the + // negative IDs that groups use) or an "@channelname" handle, so it is + // carried as a string. + ChatID string + + // AlertReasons restricts which report categories this channel carries, by + // reason name. Empty carries every reason. + // + // This is channel configuration rather than domain policy: the question it + // answers is "what does *this* channel deliver", and a second channel added + // later (email, say) would reasonably answer it differently — everything by + // mail, urgent-only by push. The names are validated against the domain + // vocabulary by the caller. + AlertReasons []string + + // Timeout bounds a single Bot API call. Values above the caller's own + // backstop (adminreports.alertTimeout, 10s) are capped by it. + Timeout time.Duration + + // CredentialsPresentWhileDisabled records that a bot token or chat ID was + // configured while Enabled is false — a half-finished setup that boots + // clean and alerts nobody. Reported rather than fatal: turning a noisy + // channel off in a hurry is a legitimate operation and must not require + // also clearing the credentials. + CredentialsPresentWhileDisabled bool + + // BaseURL overrides the Bot API origin. Tests point this at an httptest + // server; production leaves it empty and gets defaultBaseURL. + BaseURL string +} + +// Validate reports whether the configuration can produce a working client. +// A disabled config is always valid — there is nothing to get wrong. +func (c Config) Validate() error { + if !c.Enabled { + return nil + } + if strings.TrimSpace(c.BotToken) == "" { + return ErrMissingBotToken + } + if strings.TrimSpace(c.ChatID) == "" { + return ErrMissingChatID + } + if c.Timeout <= 0 { + return ErrInvalidTimeout + } + return nil +} + +// ConfigFromEnv loads the Telegram alert configuration from the environment. +// +// Unlike the image proxy's loader, a malformed value here is an error rather +// than a warning-plus-default. Every other subsystem degrades visibly when +// misconfigured; this one degrades into silence, which is indistinguishable +// from "no reports have been filed". +// +// Environment variables: +// - TELEGRAM_ALERTS_ENABLED: "true"/"false" (default: false) +// - TELEGRAM_BOT_TOKEN: bot token from @BotFather (required when enabled) +// - TELEGRAM_CHAT_ID: destination chat ID or @channelname (required when enabled) +// - TELEGRAM_ALERT_REASONS: comma-separated report reasons (default: all) +// - TELEGRAM_TIMEOUT_SECONDS: per-request timeout (default: 5) +func ConfigFromEnv() (Config, error) { + cfg := Config{Timeout: defaultTimeout} + + if raw := lookup("TELEGRAM_ALERTS_ENABLED"); raw != "" { + enabled, err := strconv.ParseBool(raw) + if err != nil { + return Config{}, fmt.Errorf("TELEGRAM_ALERTS_ENABLED: %q is not a boolean (use true/false)", raw) + } + cfg.Enabled = enabled + } + + if !cfg.Enabled { + // Presence only — the values are neither stored nor logged. The check + // exists because "filled in the credentials, never flipped the flag" is + // the single most likely way to deploy this feature and have it do + // nothing, and the loader is otherwise structurally blind to it. + cfg.CredentialsPresentWhileDisabled = + lookup("TELEGRAM_BOT_TOKEN") != "" || lookup("TELEGRAM_CHAT_ID") != "" + return cfg, nil + } + + cfg.BotToken = lookup("TELEGRAM_BOT_TOKEN") + cfg.ChatID = lookup("TELEGRAM_CHAT_ID") + + if raw := lookup("TELEGRAM_TIMEOUT_SECONDS"); raw != "" { + seconds, err := strconv.Atoi(raw) + if err != nil || seconds <= 0 { + return Config{}, fmt.Errorf("TELEGRAM_TIMEOUT_SECONDS: %q must be a positive integer", raw) + } + cfg.Timeout = time.Duration(seconds) * time.Second + } + + for _, part := range strings.Split(os.Getenv("TELEGRAM_ALERT_REASONS"), ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + cfg.AlertReasons = append(cfg.AlertReasons, trimmed) + } + } + + return cfg, cfg.Validate() +} diff --git a/internal/notify/telegram/config_test.go b/internal/notify/telegram/config_test.go new file mode 100644 index 0000000..454e1a1 --- /dev/null +++ b/internal/notify/telegram/config_test.go @@ -0,0 +1,278 @@ +package telegram + +import ( + "strings" + "testing" + "time" +) + +// telegramEnvVars is every variable ConfigFromEnv reads. Tests clear all of +// them so a value in the developer's own shell cannot change a result. +var telegramEnvVars = []string{ + "TELEGRAM_ALERTS_ENABLED", + "TELEGRAM_BOT_TOKEN", + "TELEGRAM_CHAT_ID", + "TELEGRAM_ALERT_REASONS", + "TELEGRAM_TIMEOUT_SECONDS", +} + +// clearEnv unsets every Telegram variable for the duration of the test. +func clearEnv(t *testing.T) { + t.Helper() + for _, key := range telegramEnvVars { + t.Setenv(key, "") + } +} + +// Alerting must be opt-in: a self-hosted Coves instance cannot need a Telegram +// account in order to boot. +func TestConfigFromEnv_DisabledByDefault(t *testing.T) { + clearEnv(t) + + cfg, err := ConfigFromEnv() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if cfg.Enabled { + t.Error("Telegram alerts must default to disabled") + } +} + +// A disabled config must not be judged against the enabled requirements, or an +// operator who never wanted alerts cannot start the server. +func TestConfigFromEnv_DisabledIgnoresMissingCredentials(t *testing.T) { + clearEnv(t) + t.Setenv("TELEGRAM_ALERTS_ENABLED", "false") + + cfg, err := ConfigFromEnv() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if cfg.Enabled { + t.Error("expected alerts to be disabled") + } +} + +// The most likely way to deploy this feature and have it do nothing: fill in +// the credentials, never flip the flag. The loader is otherwise structurally +// blind to it, since it returns before reading them. +func TestConfigFromEnv_FlagsCredentialsWhileDisabled(t *testing.T) { + tests := []struct { + name string + env map[string]string + want bool + }{ + {"token only", map[string]string{"TELEGRAM_BOT_TOKEN": testToken}, true}, + {"chat ID only", map[string]string{"TELEGRAM_CHAT_ID": "-1001234567890"}, true}, + {"both", map[string]string{ + "TELEGRAM_BOT_TOKEN": testToken, + "TELEGRAM_CHAT_ID": "-1001234567890", + }, true}, + {"neither", map[string]string{}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearEnv(t) + t.Setenv("TELEGRAM_ALERTS_ENABLED", "false") + for key, value := range tt.env { + t.Setenv(key, value) + } + + cfg, err := ConfigFromEnv() + if err != nil { + t.Fatalf("a half-finished setup must not fail startup, got: %v", err) + } + if cfg.Enabled { + t.Fatal("expected alerts to stay disabled") + } + if cfg.CredentialsPresentWhileDisabled != tt.want { + t.Errorf("CredentialsPresentWhileDisabled = %v, want %v", + cfg.CredentialsPresentWhileDisabled, tt.want) + } + // The presence check must not retain the credential. + if cfg.BotToken != "" || cfg.ChatID != "" { + t.Errorf("a disabled config must not carry credentials, got token=%q chat=%q", + cfg.BotToken, cfg.ChatID) + } + }) + } +} + +func TestConfigFromEnv_LoadsEnabledConfig(t *testing.T) { + clearEnv(t) + t.Setenv("TELEGRAM_ALERTS_ENABLED", "true") + t.Setenv("TELEGRAM_BOT_TOKEN", testToken) + t.Setenv("TELEGRAM_CHAT_ID", "-1001234567890") + t.Setenv("TELEGRAM_ALERT_REASONS", "csam, doxing ,illegal") + t.Setenv("TELEGRAM_TIMEOUT_SECONDS", "8") + + cfg, err := ConfigFromEnv() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if !cfg.Enabled { + t.Error("expected alerts to be enabled") + } + if cfg.BotToken != testToken { + t.Errorf("BotToken = %q, want %q", cfg.BotToken, testToken) + } + if cfg.ChatID != "-1001234567890" { + t.Errorf("ChatID = %q, want %q", cfg.ChatID, "-1001234567890") + } + if cfg.Timeout != 8*time.Second { + t.Errorf("Timeout = %v, want 8s", cfg.Timeout) + } + + want := []string{"csam", "doxing", "illegal"} + if len(cfg.AlertReasons) != len(want) { + t.Fatalf("AlertReasons = %v, want %v", cfg.AlertReasons, want) + } + for i, reason := range cfg.AlertReasons { + if reason != want[i] { + t.Errorf("AlertReasons[%d] = %q, want %q", i, reason, want[i]) + } + } +} + +// Whitespace around a value is the standard docker-compose and .env hazard. +func TestConfigFromEnv_TrimsWhitespace(t *testing.T) { + clearEnv(t) + t.Setenv("TELEGRAM_ALERTS_ENABLED", " true ") + t.Setenv("TELEGRAM_BOT_TOKEN", " "+testToken+" ") + t.Setenv("TELEGRAM_CHAT_ID", " @covesalerts ") + + cfg, err := ConfigFromEnv() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if cfg.BotToken != testToken { + t.Errorf("BotToken = %q, want it trimmed", cfg.BotToken) + } + if cfg.ChatID != "@covesalerts" { + t.Errorf("ChatID = %q, want %q", cfg.ChatID, "@covesalerts") + } +} + +func TestConfigFromEnv_DefaultsTimeout(t *testing.T) { + clearEnv(t) + t.Setenv("TELEGRAM_ALERTS_ENABLED", "true") + t.Setenv("TELEGRAM_BOT_TOKEN", testToken) + t.Setenv("TELEGRAM_CHAT_ID", "1") + + cfg, err := ConfigFromEnv() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if cfg.Timeout != defaultTimeout { + t.Errorf("Timeout = %v, want %v", cfg.Timeout, defaultTimeout) + } +} + +// Enabling alerts without credentials must fail startup. Degrading to "alerts +// off" would leave the deployment silent and looking healthy — the original +// bug. +func TestConfigFromEnv_RejectsIncompleteEnabledConfig(t *testing.T) { + tests := []struct { + name string + env map[string]string + wantErr error + }{ + { + name: "no bot token", + env: map[string]string{"TELEGRAM_CHAT_ID": "1"}, + wantErr: ErrMissingBotToken, + }, + { + name: "no chat ID", + env: map[string]string{"TELEGRAM_BOT_TOKEN": testToken}, + wantErr: ErrMissingChatID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearEnv(t) + t.Setenv("TELEGRAM_ALERTS_ENABLED", "true") + for key, value := range tt.env { + t.Setenv(key, value) + } + + if _, err := ConfigFromEnv(); err != tt.wantErr { + t.Fatalf("expected %v, got: %v", tt.wantErr, err) + } + }) + } +} + +func TestConfigFromEnv_RejectsMalformedValues(t *testing.T) { + tests := []struct { + name string + key string + value string + wantText string + }{ + {"non-boolean enabled", "TELEGRAM_ALERTS_ENABLED", "yes", "TELEGRAM_ALERTS_ENABLED"}, + {"non-integer timeout", "TELEGRAM_TIMEOUT_SECONDS", "5s", "TELEGRAM_TIMEOUT_SECONDS"}, + {"zero timeout", "TELEGRAM_TIMEOUT_SECONDS", "0", "TELEGRAM_TIMEOUT_SECONDS"}, + {"negative timeout", "TELEGRAM_TIMEOUT_SECONDS", "-1", "TELEGRAM_TIMEOUT_SECONDS"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearEnv(t) + t.Setenv("TELEGRAM_ALERTS_ENABLED", "true") + t.Setenv("TELEGRAM_BOT_TOKEN", testToken) + t.Setenv("TELEGRAM_CHAT_ID", "1") + t.Setenv(tt.key, tt.value) + + _, err := ConfigFromEnv() + if err == nil { + t.Fatalf("expected an error for %s=%q", tt.key, tt.value) + } + if !strings.Contains(err.Error(), tt.wantText) { + t.Errorf("error should name %s, got: %v", tt.wantText, err) + } + }) + } +} + +// A config error must not quote the credential it was validating. +func TestConfigFromEnv_ErrorsNeverLeakBotToken(t *testing.T) { + clearEnv(t) + t.Setenv("TELEGRAM_ALERTS_ENABLED", "true") + t.Setenv("TELEGRAM_BOT_TOKEN", testToken) + t.Setenv("TELEGRAM_CHAT_ID", "1") + t.Setenv("TELEGRAM_TIMEOUT_SECONDS", "nonsense") + + _, err := ConfigFromEnv() + if err == nil { + t.Fatal("expected an error") + } + if strings.Contains(err.Error(), testToken) { + t.Errorf("config error leaked the bot token: %v", err) + } +} + +func TestConfigValidate(t *testing.T) { + t.Run("disabled is always valid", func(t *testing.T) { + if err := (Config{Enabled: false}).Validate(); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + }) + + t.Run("rejects a non-positive timeout", func(t *testing.T) { + cfg := Config{Enabled: true, BotToken: testToken, ChatID: "1"} + if err := cfg.Validate(); err != ErrInvalidTimeout { + t.Fatalf("expected ErrInvalidTimeout, got: %v", err) + } + }) + + t.Run("accepts a complete config", func(t *testing.T) { + cfg := Config{Enabled: true, BotToken: testToken, ChatID: "1", Timeout: time.Second} + if err := cfg.Validate(); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + }) +}