package telegram // 本文件钉住 Bot API 出站调用的两条出镜边界(M41 #12/#13 那一族): // // - **非 200 的响应体原文不进错误**:旧实现把 512B 体原文当 description 拼进 // error,而 `ReadMessages` 会以 `long poll failed` 落日志 —— 上游在体里回显什么, // 日志里就有什么。现在只解 error_code 与 description(清洗、截断后)。 // - **地址里的 bot token 不进错误**:Bot API 的地址是 /bot/, // 传输层失败时 net/http 的 *url.Error 会把整条地址拼进错误串。 // // 两条都是「改回去就变红」的形状:断言的是错误文本 —— 也就是真正被写进日志的字符串。 import ( "context" "errors" "net/http" "strings" "testing" ) // failingTransport 让 a.client.Do 以传输层错误收场(只有它会让 net/http 包出带完整 // 地址的 *url.Error,那正是要钉住的泄漏形态)。 type failingTransport struct{} func (failingTransport) RoundTrip(*http.Request) (*http.Response, error) { return nil, errors.New("dial tcp: connection refused") } // TestDoJSON_StatusErrorDoesNotEchoBodySentinel 4xx 体里放哨兵 ⇒ 错误里搜不到。 // // 哨兵刻意放在 description 之外的字段:description 按设计要保留(那是 Telegram 对 // 这次请求的说明),而「体里其余字节一个字都不拼」才是要钉住的口径。 func TestDoJSON_StatusErrorDoesNotEchoBodySentinel(t *testing.T) { t.Parallel() const sentinel = "SENTINEL-TG-BODY" a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: chat not found",` + `"echoed_params":{"text":"` + sentinel + `"}}`)) })) _, err := a.doJSON(context.Background(), "sendMessage", map[string]any{"chat_id": "999"}) if err == nil { t.Fatal("400 却报成功") } if !errors.Is(err, ErrBadResponse) { t.Fatalf("err = %v, want ErrBadResponse", err) } if strings.Contains(err.Error(), sentinel) { t.Fatalf("响应体其余字段进了错误(会随 long poll failed 落日志):%v", err) } if !strings.Contains(err.Error(), "error_code 400") || !strings.Contains(err.Error(), "chat not found") { t.Fatalf("error_code/description 丢了(诊断面不能一起丢):%v", err) } if !strings.Contains(err.Error(), "status 400") { t.Fatalf("HTTP 状态码丢了:%v", err) } } // TestDoJSON_StatusErrorRedactsDescriptionSecrets description 是上游自由文本,而 // setWebhook 失败时 Telegram 会把我们给的地址回显在里面 —— 地址的 query 常带凭据。 // 这类赋值形状必须被清洗。改回原样透传就变红。 func TestDoJSON_StatusErrorRedactsDescriptionSecrets(t *testing.T) { t.Parallel() const secret = "WEBHOOK-SECRET-VALUE" a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"bad webhook ` + `https://example.com/hook?access_token=` + secret + `"}`)) })) _, err := a.doJSON(context.Background(), "setWebhook", map[string]any{"url": "https://x"}) if err == nil { t.Fatal("400 却报成功") } if strings.Contains(err.Error(), secret) { t.Fatalf("description 里的凭据原样进了错误:%v", err) } } // TestDoJSON_TransportErrorOmitsBotToken 传输层失败时,错误里不得出现 bot token —— // 它就写在 Bot API 的地址路径里。 func TestDoJSON_TransportErrorOmitsBotToken(t *testing.T) { t.Parallel() const token = "123456:AAH-SECRET-BOT-TOKEN" a := New( Config{BotToken: token}, WithBaseURL("http://api.example.com"), WithHTTPClient(&http.Client{Transport: failingTransport{}}), WithLogger(discardLogger()), ) _, err := a.doJSON(context.Background(), "getUpdates", nil) if err == nil { t.Fatal("传输层失败却报成功") } if strings.Contains(err.Error(), token) { t.Fatalf("bot token 随地址进了错误(会随 long poll failed 落日志):%v", err) } if !strings.Contains(err.Error(), "api.example.com") || !strings.Contains(err.Error(), "connection refused") { t.Fatalf("剥掉地址时把来源或原因一起丢了:%v", err) } } // TestDoJSON_BuildErrorOmitsBotToken 请求**构造**失败也是同一族:地址解析失败的 // err 同样是 *url.Error,里面就是整条 /bot/… 地址,而 poll.go 会原样落日志。 // 与传输层分支同形:只留来源,原因保留。 func TestDoJSON_BuildErrorOmitsBotToken(t *testing.T) { t.Parallel() const token = "424242:PROBE-BUILD-TOKEN" a := New( Config{BotToken: token}, // 控制字符让 url.Parse 失败 —— 这是「构造请求」分支唯一现实的触发方式。 WithBaseURL("http://api.example.com/\x7f"), WithLogger(discardLogger()), ) _, err := a.doJSON(context.Background(), "getUpdates", nil) if err == nil { t.Fatal("构造失败却报成功") } if strings.Contains(err.Error(), token) { t.Fatalf("bot token 随地址进了错误(会随 long poll failed 落日志):%v", err) } if !strings.Contains(err.Error(), "build getUpdates request") { t.Fatalf("错误没说是哪个方法的构造失败:%v", err) } if !strings.Contains(err.Error(), "net/url") && !strings.Contains(err.Error(), "invalid") { t.Fatalf("剥掉地址时把原因一起丢了:%v", err) } } // TestDoJSON_DescriptionIsBoundedByRedactText 钉住长度口径:description 只有一道 // 清洗(trace.RedactText,80 rune + 省略号)。本片曾按 200 rune 自己再截一刀,那层 // 永远命中不了 —— 这条用例让「到底哪一层在截」变成可断言的事实。 func TestDoJSON_DescriptionIsBoundedByRedactText(t *testing.T) { t.Parallel() long := strings.Repeat("a", 300) a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"` + long + `"}`)) })) _, err := a.doJSON(context.Background(), "getMe", nil) if err == nil { t.Fatal("400 却报成功") } if !strings.Contains(err.Error(), strings.Repeat("a", 80)+"…") { t.Fatalf("description 没有按 80 rune 截断:%v", err) } if strings.Contains(err.Error(), strings.Repeat("a", 81)) { t.Fatalf("description 超过了 80 rune 上限:%v", err) } }