Files
tiennm99 a8ed67a0a3 refactor: audit-driven hygiene pass across modules and infra
Concurrency
- lolschedule: serialize subscriber Get→mutate→Put via state.subscribersMu;
  the single-slot list was previously losing writes under concurrent
  /lolschedule_subscribe.
- trading: PriceClient memoises its default *http.Client so /trade_stats
  reuses TLS connections across held tickers.

Observability
- server/log_middleware: defer the req log line and recover panics so a
  panicking cron handler still emits the structured req entry CloudWatch
  filters on for 5xx alerting.
- server/router (cron): inner recover with cron-name context captures the
  panicking job before the middleware's safety net does.
- telegram/webhook: rune-safe truncation in dispatch logs — Vietnamese,
  Korean, and emoji previews no longer ship as garbled bytes.
- lolschedule/api_client: same rune-safe fix for error-body log truncation.
- telegram/webhook: gate the post-recover WriteHeader(200) so a panicking
  handler that already touched w doesn't trigger superfluous-WriteHeader.

Correctness
- twentyq: clearGame error during solved-relaunch is logged instead of
  silently swallowed (was a permanent deadlock vector on KV failure).
- misc /mstats: KV read failure replies "Could not load stats. Try again
  later." to the user instead of returning into the dispatcher; matches the
  pattern other modules use.
- migrate_cf_data trading-audit-dump: surface f.Close error so a truncated
  JSONL never passes silently as a complete audit dump.

Operator ergonomics
- migrate_cf_data (all 4 subcommands): signal.NotifyContext for SIGINT /
  SIGTERM. Ctrl-C mid-Scan now propagates cleanly instead of leaving a
  half-converted DynamoDB table.
- ai/ratelimit: doc the Lambda-recycle memory bound to match keylock.Map
  so a future reviewer doesn't re-flag the unbounded map.

I/O-changing (user-approved)
- lolschedule daily push auto-prunes subscribers whose Telegram error
  matches a terminal marker (blocked / deactivated / chat gone). Transient
  errors keep the chat on the list. Subscribe message updated to mention
  the auto-cleanup.
- twentyq seed pool grown 50 → 178; repeat-collision threshold moves from
  ~9 plays to ~17 (birthday paradox).
- util /info flipped Public → Protected — chat/thread/sender IDs are no
  longer enumerable by every group member.
- cmd/server WriteTimeout 6min → 75s (cron 60s + 15s slack). No-op on
  Lambda; matters only for local non-Lambda runs.
- webhook + cron rejection paths drop response bodies (no fingerprintable
  text for internet scanners hitting the public Function URL). Status
  codes preserved for CloudWatch metrics; structured log lines carry the
  rejection reason for operator triage.

Tests added: TestTruncateRunes, TestRunDailyPush_PrunesDeadSubscribers,
TestIsTerminalSendError, TestInfo_DeniedToNonOwner,
TestInfo_DeniedToChannelMessageNoFrom, plus owner-allowed counterparts.
2026-05-16 13:35:00 +07:00

155 lines
5.4 KiB
Go

package telegram
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
)
const testSecret = "super-secret-token"
// validUpdate is a minimal Telegram update payload that decodes cleanly. The
// bot has no handlers registered so ProcessUpdate is a no-op match.
const validUpdate = `{"update_id": 1}`
func mustBot(t *testing.T) *bot.Bot {
t.Helper()
b, err := NewBot("TEST:TOKEN")
if err != nil {
t.Fatalf("NewBot: %v", err)
}
return b
}
func TestWebhookHandler_RejectsNonPost(t *testing.T) {
h := WebhookHandler(mustBot(t), testSecret)
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", rec.Code)
}
}
func TestWebhookHandler_RejectsMissingSecret(t *testing.T) {
h := WebhookHandler(mustBot(t), testSecret)
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(validUpdate))
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
func TestWebhookHandler_RejectsWrongSecret(t *testing.T) {
h := WebhookHandler(mustBot(t), testSecret)
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(validUpdate))
req.Header.Set(secretTokenHeader, "wrong")
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
func TestWebhookHandler_RejectsWrongSecretSamePrefix(t *testing.T) {
// Locks the constant-time compare: a value sharing a prefix must still
// 401, not silently succeed.
h := WebhookHandler(mustBot(t), testSecret)
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(validUpdate))
req.Header.Set(secretTokenHeader, testSecret[:len(testSecret)-1]+"X")
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rec.Code)
}
}
func TestWebhookHandler_RejectsMalformedJSON(t *testing.T) {
h := WebhookHandler(mustBot(t), testSecret)
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader("not-json"))
req.Header.Set(secretTokenHeader, testSecret)
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestWebhookHandler_RejectsOversizedBody(t *testing.T) {
h := WebhookHandler(mustBot(t), testSecret)
// Valid-prefixed JSON so the decoder doesn't bail on the first byte; the
// long string field forces a read past maxWebhookBody, triggering
// *http.MaxBytesError. Plain "aaaa…" without the JSON wrapper would fail
// at byte 1 with a SyntaxError and never exercise the cap.
body := bytes.Buffer{}
body.WriteString(`{"update_id":1,"message":{"text":"`)
body.Write(bytes.Repeat([]byte("a"), maxWebhookBody+1))
body.WriteString(`"}}`)
req := httptest.NewRequest(http.MethodPost, "/webhook", &body)
req.Header.Set(secretTokenHeader, testSecret)
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want 413", rec.Code)
}
}
func TestWebhookHandler_AcceptsValidUpdate(t *testing.T) {
h := WebhookHandler(mustBot(t), testSecret)
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(validUpdate))
req.Header.Set(secretTokenHeader, testSecret)
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
func TestTruncateRunes_KeepsUTF8Valid(t *testing.T) {
// Single-byte (ASCII): output must equal a byte slice when boundary aligns.
if got := truncateRunes("hello world", 5); got != "hello" {
t.Errorf("ascii: got %q, want %q", got, "hello")
}
// Multi-byte (Vietnamese): max=5 bytes, "ầ" is 3 bytes ("\xe1\xba\xa7").
// "h" (1) + "ầ" (3) = 4 bytes; next rune would push to 7. truncate at 5
// would land mid-rune; the helper must walk back to byte 4 so the slice
// ends on a rune boundary and the result decodes cleanly.
if got := truncateRunes("hầuhầuhầu", 5); got != "hầu" {
t.Errorf("vietnamese: got %q (len %d), want %q (len %d)", got, len(got), "hầu", len("hầu"))
}
// Length-below-cap path: pass through unchanged.
if got := truncateRunes("abc", 10); got != "abc" {
t.Errorf("short: got %q, want %q", got, "abc")
}
}
// panicUpdate matches the panicHandler registered below by /panic command.
const panicUpdate = `{"update_id":2,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"from":{"id":1,"is_bot":false,"first_name":"x"},"text":"/panic","entities":[{"type":"bot_command","offset":0,"length":6}]}}`
func TestWebhookHandler_RecoversPanicAndReturns200(t *testing.T) {
// A panicking handler must NOT propagate to the http.Server (would close
// the response mid-write and trigger Telegram's 24-hour retry storm on the
// same poisoned update). Recovery returns 200; Telegram does not retry.
b := mustBot(t)
b.RegisterHandler(bot.HandlerTypeMessageText, "panic", bot.MatchTypeCommand,
func(ctx context.Context, _ *bot.Bot, _ *models.Update) {
panic("boom")
})
h := WebhookHandler(b, testSecret)
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(panicUpdate))
req.Header.Set(secretTokenHeader, testSecret)
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200 after recover", rec.Code)
}
}