package trace import ( "bytes" "encoding/json" "errors" "log/slog" "strings" "testing" ) // 本文件钉住 M40 的**写侧**(stderr JSONL)边界:写下去的每一行都可以被 // `tail`/采集/粘进工单,因此它只允许携带脱敏后的投影 —— 正文、工具结果原文、 // 子代理回复原文、上游响应体原文,一个字节都不许出现。 // // 🔴 断言一律打在**落盘输出/序列化结果**上(JSONL 字节、slog 输出), // 不打在 error.Error() 的措辞上:措辞会随实现变,泄漏不会。 // sentinelToolBody 是「工具结果正文」的哨兵:只要它出现在落盘里,就说明写侧 // 又把正文抄了一遍。 const sentinelToolBody = "SENTINEL-TOOL-RESULT-9f3a2b-一段不该落盘的正文" // writeRecords 把若干 span 记录经 JSONRecorder 写进缓冲区并解码回来。它直接用 // 记录(而不是跑一次真循环)是为了让「写侧投影」这件事在 trace 包内自证; // 端到端那条链由 loop/orchestration 两个包的测试钉住。 func writeRecords(t *testing.T, recs ...SpanRecord) ([]SpanRecord, string) { t.Helper() var buf bytes.Buffer rec := NewJSONRecorder(&buf) for _, r := range recs { if err := rec.WriteSpan(r); err != nil { t.Fatalf("WriteSpan: %v", err) } } return decodeLines(t, buf.Bytes()), buf.String() } // TestJSONRecorder_KeepsSessionReadableDropsBody 是「日志里 session 可读、正文仍 // 不可见」**同一条断言**(主人 2026-10-26 裁定放行 session): // // - session 是业务标识、不是正文,进白名单后必须**原文可读**(排障要把一条 // stderr 日志对回某个会话;变成 Digest 就失去用途); // - 同一 span 里的正文(result / text)仍必须落不到盘上 —— 这一条防的是 // 「顺手放宽了别的东西」:白名单加一个键,正文的边界不能跟着动。 func TestJSONRecorder_KeepsSessionReadableDropsBody(t *testing.T) { t.Parallel() const session = "web#lane-2" rec := spanRec("sess1", "", "tool.fs.read", SpanKindTool, SpanStatusOK, map[string]any{ "session": session, "result": sentinelToolBody, "text": sentinelToolBody, }) recs, raw := writeRecords(t, rec) if !strings.Contains(raw, `"session":"`+session+`"`) { t.Fatalf("JSONL 里 session 不可读(它是排障对回会话的唯一线索):%s", raw) } if strings.Contains(raw, sentinelToolBody) { t.Fatalf("放行 session 的同时正文也落盘了:%s", raw) } got := byID(recs, "sess1") if got.Props["session"] != session { t.Fatalf("session prop = %v, want 原文 %q", got.Props["session"], session) } for _, key := range []string{"result", "text"} { if _, ok := got.Props[key]; ok { t.Fatalf("表外 prop %q 落盘了:%+v", key, got.Props) } if d := omittedEntry(t, got, key); d.SHA256 == "" || d.Bytes == 0 { t.Fatalf("omitted 里没有 %q 的体积/指纹:%+v", key, d) } } } // TestJSONRecorder_DropsRawContentAndKeepsDigest 是写侧投影的主钉: // - 表外的 `result` / `text`(正文形态)绝不落盘; // - 白名单键仍落盘(图上与日志里要能看见 tool / error 之类的元数据); // - 被丢弃的键折进 omitted,留下 bytes/runes/sha256 —— 于是「同一次工具调用 // 成功还是失败、输出多大」仍可分辨(这是「脱敏不能把 span 变得没用」的验收点); // - 字符串值一律过 RedactText(凭据形状打码)。 func TestJSONRecorder_DropsRawContentAndKeepsDigest(t *testing.T) { t.Parallel() okRec := spanRec("s1", "", "tool.fs.read", SpanKindTool, SpanStatusOK, map[string]any{ "tool": "fs.read", "result": sentinelToolBody, "result_summary": "SENTINEL-TOOL", "result_digest": DigestOf(sentinelToolBody), }) badRec := spanRec("s2", "", "tool.fs.read", SpanKindTool, SpanStatusError, map[string]any{ "tool": "fs.read", "result": strings.Repeat("错", 400), "error": "read failed: token=abcdef1234567890", "text": sentinelToolBody, "subagent": "sa-1", }) recs, raw := writeRecords(t, okRec, badRec) if strings.Contains(raw, sentinelToolBody) { t.Fatalf("写侧 JSONL 里出现了工具结果/子代理回复原文:%s", raw) } if strings.Contains(raw, "abcdef1234567890") { t.Fatalf("写侧 JSONL 里出现了凭据原文(字符串值没走 RedactText):%s", raw) } if n := strings.Count(strings.TrimSpace(raw), "\n") + 1; n != 2 { t.Fatalf("落盘行数 = %d, want 2(一行一个 span): %s", n, raw) } okSpan, badSpan := byID(recs, "s1"), byID(recs, "s2") // 表外键不得以原值出现。 for _, key := range []string{"result", "text"} { if _, ok := badSpan.Props[key]; ok { t.Fatalf("表外 prop %q 落盘了:%+v", key, badSpan.Props) } } // 白名单键照旧落盘:tool / result_summary / error / subagent。 if okSpan.Props["tool"] != "fs.read" || okSpan.Props["result_summary"] != "SENTINEL-TOOL" { t.Fatalf("白名单键没落盘:%+v", okSpan.Props) } if _, ok := badSpan.Props["subagent"]; !ok { t.Fatalf("白名单键 subagent 没落盘:%+v", badSpan.Props) } if got, _ := badSpan.Props["error"].(string); !strings.Contains(got, redactedMarker) { t.Fatalf("error prop = %v,凭据没被打码", badSpan.Props["error"]) } // 诊断线索:成功/失败可分辨(status + Digest),输出大小可分辨(bytes)。 if okSpan.Status != SpanStatusOK || badSpan.Status != SpanStatusError { t.Fatalf("status = %q/%q,成功与失败必须仍可分辨", okSpan.Status, badSpan.Status) } okDropped := omittedEntry(t, okSpan, "result") badDropped := omittedEntry(t, badSpan, "result") if okDropped.Bytes != len(sentinelToolBody) { t.Fatalf("omitted.result.bytes = %d, want %d(输出大小必须仍可读)", okDropped.Bytes, len(sentinelToolBody)) } if badDropped.Bytes != len(strings.Repeat("错", 400)) { t.Fatalf("omitted.result.bytes = %d, want %d", badDropped.Bytes, len(strings.Repeat("错", 400))) } if okDropped.SHA256 == "" || okDropped.SHA256 == badDropped.SHA256 { t.Fatalf("omitted.result.sha256 = %q/%q,want 两份不同内容的指纹不同", okDropped.SHA256, badDropped.SHA256) } // Digest 型 prop(生产侧明确声明「这不是正文」)原样落盘,且不再被再摘要一次。 if got, ok := okSpan.Props["result_digest"].(map[string]any); !ok { t.Fatalf("result_digest 没落盘:%+v", okSpan.Props) } else if int(got["bytes"].(float64)) != len(sentinelToolBody) { t.Fatalf("result_digest.bytes = %v, want %d", got["bytes"], len(sentinelToolBody)) } } // TestJSONRecorder_SubagentReplyNeverLands 钉住子代理回复这条路径:整段回复 // 曾经以 `text` prop 与 `summary` 事件值的形式原样落盘(M40 修掉的最严重一条)。 // 现在 span 上只有 text_digest,事件上只有 chars/sha256。 func TestJSONRecorder_SubagentReplyNeverLands(t *testing.T) { t.Parallel() const reply = "SENTINEL-SUBAGENT-REPLY-77c1-她说的整段话" rec := spanRec("sa", "", "subagent.sa-1", SpanKindSubagent, SpanStatusOK, map[string]any{ "text": reply, "text_digest": DigestOf(reply), }) rec.Events = []Event{{Type: "subagent.done", Props: map[string]any{ "status": "done", "steps": 2, "chars": len([]rune(reply)), "sha256": DigestOf(reply).SHA256, "text": reply, }}} recs, raw := writeRecords(t, rec) if strings.Contains(raw, reply) { t.Fatalf("写侧 JSONL 里出现了子代理回复原文:%s", raw) } got := byID(recs, "sa") if _, ok := got.Props["text"]; ok { t.Fatalf("子代理 span 的 text prop 落盘了:%+v", got.Props) } if d, ok := got.Props["text_digest"].(map[string]any); !ok || d["sha256"] == "" { t.Fatalf("text_digest 缺失:%+v", got.Props) } if n := len(got.Events); n != 1 { t.Fatalf("events = %d, want 1", n) } if ev := got.Events[0]; ev.Props["text"] != nil { // 正文键的值必须已被换成 Digest(键留在原处,值不含正文)。 d, ok := ev.Props["text"].(map[string]any) if !ok || d["sha256"] == "" { t.Fatalf("事件里的正文键没折成 Digest:%+v", ev.Props) } } } // TestUpstreamError_DropsEchoedBody 是共享 helper 的行为钉(M41/M42 的用法模板): // 一个**回显请求正文**的 422 报文,经 UpstreamError 收敛后,错误文本与日志里都 // 搜不到那段回显,但 status / code / type 仍可辨。 func TestUpstreamError_DropsEchoedBody(t *testing.T) { t.Parallel() const echoedInput = "SENTINEL-USER-INPUT-5d2e-用户在聊天框里说的话" body := []byte(`{"error":{"code":"invalid_request_error","type":"invalid_request",` + `"message":"Invalid value for 'messages[0].content'."},` + `"param":"messages[0].content","input":"` + echoedInput + `",` + `"echo":{"messages":[{"role":"user","content":"` + echoedInput + `"}]}}`) err := UpstreamError("api.openai.example", 422, "application/json; charset=utf-8", body) var buf bytes.Buffer log := slog.New(slog.NewJSONHandler(&buf, nil)) log.Error("upstream call failed", "err", err) text := err.Error() for _, surface := range []string{text, buf.String()} { if strings.Contains(surface, echoedInput) { t.Fatalf("响应体回显的输入原样出现了:%s", surface) } if strings.Contains(surface, "messages[0].content") && strings.Contains(surface, "role") { t.Fatalf("响应体的非白名单字段被带出来了:%s", surface) } } for _, want := range []string{"status=422", "invalid_request_error", "type=invalid_request", "api.openai.example"} { if !strings.Contains(text, want) { t.Fatalf("error = %q,want 含 %q(诊断信息不能一起丢掉)", text, want) } } if !errors.Is(err, ErrUpstream) { t.Fatalf("errors.Is(err, ErrUpstream) = false;降级链无法按类别判断") } info, ok := err.(*UpstreamErrorInfo) if !ok { t.Fatalf("err 类型 = %T, want *UpstreamErrorInfo", err) } if info.BodyLen != len(body) || info.ContentType != "application/json" || info.BodySHA256 == "" { t.Fatalf("info = %+v,want bodyLen/contentType/sha256 作为诊断线索", info) } // 默认构造连上游那句人话(error.message)也不带:它的措辞里就可能嵌着用户内容 // (这个报文里是 'messages[0].content'),默认安全口径见上游文件头注释。 if info.Message != "" { t.Fatalf("默认构造的 Message = %q,want 空", info.Message) } // Attrs() 是日志侧的第二出口,必须与 Error() 同源(同样不含正文)。 attrs := info.Attrs() if joined := strings.TrimSpace(strings.Join(anyToStrings(attrs), " ")); strings.Contains(joined, echoedInput) { t.Fatalf("Attrs() 带出了回显正文:%s", joined) } } // TestUpstreamError_NonJSONBodyKeepsOnlyMeta 钉住「非 JSON 也绝不回落原文」: // HTML 错误页 / 代理错误页 / 被截断的报文只留 status 与体积指纹。 func TestUpstreamError_NonJSONBodyKeepsOnlyMeta(t *testing.T) { t.Parallel() const echoed = "SENTINEL-PROXY-ECHO-0b71" body := []byte("upstream said: " + echoed + "") err := UpstreamError("", 502, "text/html", body) if strings.Contains(err.Error(), echoed) { t.Fatalf("非 JSON 响应体原文进了错误:%s", err.Error()) } if !strings.Contains(err.Error(), "status=502") || !strings.Contains(err.Error(), "body_bytes=") { t.Fatalf("error = %q,want status 与 body_bytes 仍在", err.Error()) } } // TestUpstreamError_DefaultDropsMessage 是 P2 收口的行为钉(M41/M42 的模板): // 上游把**用户内容**拼进 error.message 时,**默认构造**必须一个字都不带出来。 // 这不是理论担心 —— `{"error":{"message":"Invalid value: <用户原文>"}}` 是真实 // 存在的写法,默认保留前 128 rune 就够把一段原话抄进日志。 // // 断言同时要求「诊断仍可辨」:status / code / type / bodyLen / bodySHA256 都还在, // 它们才是分类与降级的依据。 func TestUpstreamError_DefaultDropsMessage(t *testing.T) { t.Parallel() const sentinel = "SENTINEL-USER-INPUT-IN-MESSAGE-3c9f-她在输入框里写的话" body := []byte(`{"error":{"code":"invalid_request_error","type":"invalid_request",` + `"message":"Invalid value: ` + sentinel + `"}}`) err := UpstreamError("api.example.com", 400, "application/json", body) var logBuf bytes.Buffer log := slog.New(slog.NewJSONHandler(&logBuf, nil)) log.Error("upstream call failed", "err", err) for _, surface := range []string{err.Error(), logBuf.String()} { if strings.Contains(surface, sentinel) { t.Fatalf("默认构造把 error.message 里的用户内容带出来了:%s", surface) } } info := err.(*UpstreamErrorInfo) if info.Message != "" { t.Fatalf("默认构造的 Message = %q,want 空(message 必须显式 opt-in)", info.Message) } for _, want := range []string{"status=400", "invalid_request_error", "type=invalid_request", "body_bytes="} { if !strings.Contains(err.Error(), want) { t.Fatalf("error = %q,want 含 %q(丢掉 message 不等于丢掉诊断)", err.Error(), want) } } if info.BodyLen != len(body) || info.BodySHA256 == "" { t.Fatalf("info = %+v,want bodyLen/bodySHA256 仍用于相关性比对", info) } // Attrs() 同样不得旁路带出 message。 if joined := strings.Join(anyToStrings(info.Attrs()), " "); strings.Contains(joined, sentinel) { t.Fatalf("Attrs() 带出了 message 里的用户内容:%s", joined) } } // TestUpstreamError_OptInMessageIsSanitized 钉住 opt-in 侧的边界:显式 // WithUpstreamMessage 之后拿到的 message 仍**必须**过 RedactText(凭据打码)并 // 截断到 maxUpstreamMessageRunes —— opt-in 放宽的是「带不带人话」,不是「带多脏」。 func TestUpstreamError_OptInMessageIsSanitized(t *testing.T) { t.Parallel() const secret = "abcdef1234567890" // 凭据放在开头(截断窗口内),后面接一大段正文,验证「打码 + 截断」两件事。 message := "lookup failed: token=" + secret + " " + strings.Repeat("详", 400) body := []byte(`{"error":{"code":"bad","type":"invalid_request","message":"` + message + `"}}`) err := UpstreamError("p", 400, "application/json", body, WithUpstreamMessage()) info := err.(*UpstreamErrorInfo) if info.Message == "" { t.Fatal("opt-in 之后 Message 仍为空") } if strings.Contains(info.Message, secret) { t.Fatalf("opt-in 的 message 带着凭据原文:%q", info.Message) } if !strings.Contains(info.Message, redactedMarker) { t.Fatalf("opt-in 的 message = %q,want 含 %s(必须走 RedactText)", info.Message, redactedMarker) } if n := len([]rune(info.Message)); n > maxUpstreamMessageRunes+1 { t.Fatalf("message 长度 = %d rune,超过上限 %d", n, maxUpstreamMessageRunes) } if !strings.HasPrefix(info.Message, "lookup failed:") { t.Fatalf("message = %q,want 保留上游诊断语的前缀", info.Message) } // opt-in 只放宽 message:凭据不许旁路进 Error() 的其它字段。 if strings.Contains(err.Error(), secret) { t.Fatalf("opt-in 后 Error() 带着凭据原文:%s", err.Error()) } } // TestHostOf_DropsPathQueryAndUserInfo 钉住「日志里的 URL 只有来源」。 func TestHostOf_DropsPathQueryAndUserInfo(t *testing.T) { t.Parallel() cases := []struct { name string in string want string }{ {"普通 URL", "https://cdn.example.com/a/b?token=SECRET&x=1", "https://cdn.example.com"}, {"带端口", "http://api.example.com:8443/v1/chat", "http://api.example.com:8443"}, {"带 user:pass", "https://user:pass@h/x", "https://[redacted]@h"}, {"相对路径", "/a/b?token=SECRET", ""}, {"畸形串", "http://[::1", ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() got := HostOf(tc.in) if got != tc.want { t.Fatalf("HostOf(%q) = %q, want %q", tc.in, got, tc.want) } for _, leak := range []string{"SECRET", "pass", "user", "/a/"} { if strings.Contains(got, leak) { t.Fatalf("HostOf(%q) = %q 泄漏了 %q", tc.in, got, leak) } } }) } } // TestMaskQuery_MasksCredentialParams 钉住「查询串里的凭据值被打码」:OAuth token、 // 预签名 URL 的签名、API key 一律不明文出现,其余参数保持可读。 func TestMaskQuery_MasksCredentialParams(t *testing.T) { t.Parallel() cases := []struct { name string in string wantHas []string leaks []string }{ { name: "access_token 打码", in: "https://api.example.com/v1/x?access_token=xxxSECRETxxx&page=2", wantHas: []string{"access_token=***", "page=2"}, leaks: []string{"xxxSECRETxxx"}, }, { name: "user:pass 整体打码", in: "https://user:pass@h/x?api_key=k3ySECRET&sig=SIGSECRET", wantHas: []string{"[redacted]@h", "api_key=***"}, leaks: []string{"pass", "k3ySECRET", "SIGSECRET"}, }, { name: "片段里的 token 同样打码", in: "https://h/cb#access_token=FRAGSECRET&state=ok", wantHas: []string{"access_token=***", "state=ok"}, leaks: []string{"FRAGSECRET"}, }, { name: "非 URL 不回原串", in: "token=RAW&x=1", leaks: []string{"RAW"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() got := MaskQuery(tc.in) for _, want := range tc.wantHas { if !strings.Contains(got, want) { t.Fatalf("MaskQuery(%q) = %q, want 含 %q", tc.in, got, want) } } for _, leak := range tc.leaks { if strings.Contains(got, leak) { t.Fatalf("MaskQuery(%q) = %q 泄漏了 %q", tc.in, got, leak) } } }) } } // TestJSONRecorder_DoesNotMutateCallerRecord 钉住投影是「拷贝」而非「就地改」: // 调用方(例如 gateway 的 trace 环形缓冲出口)仍拿得到完整记录。 func TestJSONRecorder_DoesNotMutateCallerRecord(t *testing.T) { t.Parallel() rec := spanRec("m1", "", "tool.x", SpanKindTool, SpanStatusOK, map[string]any{"result": sentinelToolBody}) if _, raw := writeRecords(t, rec); strings.Contains(raw, sentinelToolBody) { t.Fatalf("写侧泄漏了原文:%s", raw) } if got, _ := rec.Props["result"].(string); got != sentinelToolBody { t.Fatalf("调用方记录被就地改写了:%+v", rec.Props) } } // TestSanitizeRecordKeepsIdentity 钉住「脱敏不动身份」:id/parent/name/kind/status // 与时间戳原样保留,否则落盘的链路就串不起来了。 func TestSanitizeRecord_KeepsIdentity(t *testing.T) { t.Parallel() rec := spanRec("c1", "p1", "llm.step2", SpanKindLLM, SpanStatusError, map[string]any{"prompt": "正文"}) out := SanitizeRecord(rec) if out.ID != rec.ID || out.ParentID != rec.ParentID || out.Name != rec.Name || out.Kind != rec.Kind || out.Status != rec.Status || !out.StartedAt.Equal(rec.StartedAt) || !out.EndedAt.Equal(rec.EndedAt) { t.Fatalf("sanitizeRecord 改动了身份字段:%+v vs %+v", out, rec) } if _, ok := out.Props["prompt"]; ok { t.Fatalf("表外 prop prompt 落盘了:%+v", out.Props) } if _, err := json.Marshal(out); err != nil { t.Fatalf("脱敏后的记录不可编码:%v", err) } } // byID 在解码后的记录里按 id 找一条,找不到即失败。 func byID(recs []SpanRecord, id string) SpanRecord { for _, r := range recs { if r.ID == id { return r } } panic("span record not found: " + id) } // omittedEntry 反读某条记录 omitted 表里被丢弃的键的 Digest。 func omittedEntry(t *testing.T, rec SpanRecord, key string) Digest { t.Helper() omitted, ok := rec.Props[omittedPropKey].(map[string]any) if !ok { t.Fatalf("props 里没有 %q 表:%+v", omittedPropKey, rec.Props) } entry, ok := omitted[key].(map[string]any) if !ok { t.Fatalf("%s 表里没有 %q 的 Digest:%+v", omittedPropKey, key, omitted) } return Digest{ Bytes: int(entry["bytes"].(float64)), Runes: int(entry["runes"].(float64)), SHA256: entry["sha256"].(string), } } // anyToStrings 把 Attrs() 的键值对压成字符串切片,便于做「搜不到」断言。 func anyToStrings(attrs []any) []string { out := make([]string, 0, len(attrs)) for _, a := range attrs { out = append(out, strings.Join(strings.Fields(toString(a)), " ")) } return out } // toString 渲染一个 Attr 值(只用于测试断言)。 func toString(v any) string { switch x := v.(type) { case string: return x case nil: return "" default: b, err := json.Marshal(x) if err != nil { return "" } return string(b) } }