package telegram // 本文件钉住 Bot API 出站调用的三条出镜边界(M41 #12/#13 那一族,M50 补 description): // // - **非 200 的响应体原文不进错误**:旧实现把 512B 体原文当 description 拼进 // error,而 `ReadMessages` 会以 `long poll failed` 落日志 —— 上游在体里回显什么, // 日志里就有什么。现在只解 error_code 与 HTTP status。 // - **description 默认不带**:它是上游自由文本,Telegram 会回显我们发过去的参数 // (里面就有她的话)。要带必须显式 opt-in;本适配器不开。 // - **地址里的 bot token 不进错误**:Bot API 的地址是 /bot/, // 传输层失败时 net/http 的 *url.Error 会把整条地址拼进错误串。 // // 三条都是「改回去就变红」的形状:断言的是错误文本 —— 也就是真正被写进日志的字符串。 import ( "bytes" "context" "errors" "log/slog" "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 之内:description 是上游自由文本面, // **默认不带**(M50),因此两个位置的哨兵都必须搜不到;留下的诊断面是 status 与 // error_code。 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: can't parse entities: ` + sentinel + `",` + `"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) } var logBuf bytes.Buffer slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("long poll failed", "err", err) for _, surface := range []string{err.Error(), logBuf.String()} { if strings.Contains(surface, sentinel) { t.Fatalf("响应体原文(含 description 回显)进了错误或日志:%s", surface) } } // 变异:把 description 拼回 parts ⇒ 上面那条立刻红。 if !strings.Contains(err.Error(), "error_code 400") { t.Fatalf("error_code 丢了(诊断面不能一起丢):%v", err) } if !strings.Contains(err.Error(), "status 400") { t.Fatalf("HTTP 状态码丢了:%v", err) } } // TestDoJSON_StatusErrorRedactsDescriptionSecrets 上一族里的凭据形态:description 现在 // 整段不带,因此 setWebhook 回显的地址(query 常带凭据)也一并不能出镜。这条与 // TestDoJSON_StatusErrorDoesNotEchoBodySentinel 是同一处代码的两个变异方向 —— 一个钉 // 「正文不进来」,一个钉「凭据不出去」。 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) } if !strings.Contains(err.Error(), "error_code 400") || !strings.Contains(err.Error(), "status 400") { 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_DescriptionIsDropped 钉住「description 默认不带」:无论它多长、写的是什么, // 错误里都只剩 status + error_code。变异:把 description 拼回 badResponseError 的 // parts ⇒ 本用例立刻红(`aaaa…` 会出现)。 func TestDoJSON_DescriptionIsDropped(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(), "aaaa") { t.Fatalf("description 进了错误(M50 口径是默认不带):%v", err) } if !strings.Contains(err.Error(), "status 400") || !strings.Contains(err.Error(), "error_code 400") { t.Fatalf("剥掉 description 时把分类信息一起丢了:%v", err) } }