mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-06-08 20:14:23 +00:00
a8ed67a0a3
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.
155 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|