Files
Cyrene_v2/internal/runtime/trace/upstream_token_guard_test.go
T
AskaEth fe3872ee28 fix(trace): 令牌守卫上移进 trace.UpstreamError,构造即安全
M48 复审的中危 finding:error.type 里的散文经 trace.UpstreamError 原样出镜,
因为守卫加在调用方(M46 的 model/voice 两处),构造函数自己不套守卫 ——
任何新调用点默认不安全,httpclient / vision / embedding / weixin 四条出口一直漏。

- trace.UpstreamError 构造时对 error.code / error.type 各走一次 ErrorToken
  ([A-Za-z0-9_.-]{<=64},不合规即空串):默认安全,不是可选加固。
- 构造后注入这条窄路收进 UpstreamErrorInfo.SetCode / SetType(同一道守卫),
  供 DashScope 顶层 code 与 Anthropic 流内 error.type 两处使用 —— 调用方不再
  各抄一份 ErrorToken。
- 删除因此冗余的调用方守卫:model.providerUpstreamError 与
  voice.dashscopeOKBodyError。保留 voice 的 request_id 守卫(它不是 code/type,
  构造函数看不见)。
- 四条漏的出口一行没改,只加用例证明守卫上移后自动安全;摘掉构造函数守卫时
  四条用例 + model/voice 旧用例同时变红。
2026-10-26 19:13:38 +08:00

97 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package trace
// 本文件是 M49 的核心断言:**守卫在构造函数里**。
//
// M46 把令牌守卫加在调用方(model / voice 各一份),M48 复审的中危 finding 证明那是
// 漂移形态 —— 构造函数自己不守,任何新调用点默认就不安全,四条出口(httpclient /
// vision / embedding / weixin)恰恰一直漏着。这里把断言钉在 UpstreamError 与
// SetCode / SetType 上:摘掉构造函数里的守卫,本文件与四条出口的用例同时变红。
//
// 每条都是「把它放宽就变红」的形状:断言的是真正进日志的两个出口(Error() / Attrs())。
import (
"bytes"
"errors"
"log/slog"
"strings"
"testing"
)
// TestUpstreamError_ConstructorDropsFreeTextCodeAndType 报文里 error.code 与
// error.type 都是回显的散文 ⇒ 两个字段在构造时就变空串,而 status / body_bytes /
// body_sha256 仍在(诊断面不能一起丢)。
func TestUpstreamError_ConstructorDropsFreeTextCodeAndType(t *testing.T) {
t.Parallel()
const prose = "Invalid value: '她刚才说的那句话'"
body := []byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`)
err := UpstreamError("api.example.com", 400, "application/json", body)
info, ok := err.(*UpstreamErrorInfo)
if !ok {
t.Fatalf("err 类型 = %T, want *UpstreamErrorInfo", err)
}
if info.Code != "" || info.Type != "" {
t.Fatalf("info.Code/Type = %q/%q,want 空串(构造即安全)", info.Code, info.Type)
}
var logBuf bytes.Buffer
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("upstream call failed", "err", err)
for _, surface := range []string{err.Error(), logBuf.String(), strings.Join(anyToStrings(info.Attrs()), " ")} {
if strings.Contains(surface, "她刚才说的那句话") {
t.Fatalf("散文经 code/type 进了出口:%s", surface)
}
if strings.Contains(surface, " code=") || strings.Contains(surface, " type=") {
t.Fatalf("非令牌形状的 code/type 没有被丢弃:%s", surface)
}
}
for _, want := range []string{"status=400", "body_bytes=", "body_sha256=", "api.example.com"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q,want 含 %q(诊断线索不能一起丢)", err.Error(), want)
}
}
if !errors.Is(err, ErrUpstream) {
t.Fatal("errors.Is(err, ErrUpstream) = false;降级链无法按类别判断")
}
}
// TestUpstreamError_ConstructorKeepsProtocolTokenCodeAndType 真实的协议枚举必须原样
// 通过:丢了它们只是少一条分类提示,但那是**有价值的**提示,别顺手一起丢。
func TestUpstreamError_ConstructorKeepsProtocolTokenCodeAndType(t *testing.T) {
t.Parallel()
body := []byte(`{"error":{"code":"invalid_request_error","type":"Throttling.RateQuota"}}`)
err := UpstreamError("api.example.com", 429, "application/json", body)
info := err.(*UpstreamErrorInfo)
if info.Code != "invalid_request_error" || info.Type != "Throttling.RateQuota" {
t.Fatalf("info.Code/Type = %q/%q,want 原值(令牌形状必须保留)", info.Code, info.Type)
}
for _, want := range []string{"code=invalid_request_error", "type=Throttling.RateQuota"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q,want 含 %q", err.Error(), want)
}
}
}
// TestUpstreamError_SetCodeGuardsPostConstructionInjection 构造后注入这条窄路也必须
// 是守卫点:SetCode / SetType 是唯一合法通道,散文进去 = 空串出来(覆盖语义)。
func TestUpstreamError_SetCodeGuardsPostConstructionInjection(t *testing.T) {
t.Parallel()
const prose = "Invalid value: '她刚才说的那句话'"
info := UpstreamError("dashscope.example.com", 400, "application/json", nil).(*UpstreamErrorInfo)
info.SetCode("Throttling.RateQuota")
info.SetType("rate-limit")
if info.Code != "Throttling.RateQuota" || info.Type != "rate-limit" {
t.Fatalf("info.Code/Type = %q/%q,want 令牌原值", info.Code, info.Type)
}
// 注入不受控的散文:两个字段都变空串(而不是保留上一次的值)。
info.SetCode(prose)
info.SetType(prose)
if info.Code != "" || info.Type != "" {
t.Fatalf("info.Code/Type = %q/%q,want 空串(SetXxx 必须过同一道守卫)", info.Code, info.Type)
}
if text := info.Error(); strings.Contains(text, "她刚才说的那句话") {
t.Fatalf("SetCode/SetType 让散文进了错误文本:%s", text)
}
}