复审 P2 收口。上一条留的漏法(不是理论,是构造得出的):
`{"error":{"code":"invalid_request_error","message":"Invalid value: <用户原文>"}}`
—— 默认保留前 128 rune,那 128 rune 就会**原样**进 error 与日志。此前的用例只钉了
input/echo 这类 sibling 字段,没钉到 message 这一面;而 M41(embedding input 是她的
记忆正文、DashScope TTS input 是她要念的话)与 M42(渠道响应体)都把这个 helper 当
模板,默认带 message 等于把洞复制到十几处。
改法(默认安全,放宽必须写出来):
- `UpstreamError(upstream, status, contentType, body, opts ...UpstreamOption)`:
最常用的四参调用**天然是最安全的那个**;默认只留 Upstream/Status/ContentType/
BodyLen/BodySHA256/Code/Type;
- 新增 `WithUpstreamMessage()` 作为显式开关:带 message 时**必须**过 RedactText
(凭据形状打码 + 压平空白)再截到 maxUpstreamMessageRunes,并在注释里写明
「只有在你确认该上游不会回显用户内容时才可用」;
- 文件头写清取舍:**宁可少一条人话提示,也不让她的原话进日志**;排障靠
status + code + type + bodyLen + sha256 通常足够分类(400 还是 429、是
model_not_found 还是 context_length_exceeded、是不是同一份报文),细节要看时
报文在调用点上下文里还在。
测试(可变异打红):
- 新增 TestUpstreamError_DefaultDropsMessage:message 里塞哨兵的 400 ⇒ 默认构造下
error 与 slog 输出都搜不到哨兵,Message 为空,而 status/code/type/bodyLen/sha256
仍可辨;Attrs() 也不旁路带出;
- 新增 TestUpstreamError_OptInMessageIsSanitized:opt-in 后 message 被截到 128 rune
且凭据被打码(超长 + 带 token 的 message);
- 既有 TestUpstreamError_DropsEchoedBody 补一条「默认 Message 为空」;
- 变异验证:把默认构造改回带 message ⇒ 前两条变红;让 opt-in 跳过 RedactText ⇒
opt-in 那条变红。
回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿(backthink 的两条
2s 定时用例在满并发下偶发超时,单元重跑与整仓重跑均绿,与本改动无关:该包不引用
trace);trace 包另跑 -race 绿。只动 upstream.go 与其测试,白名单/session/loop/
orchestration 未动。
498 lines
20 KiB
Go
498 lines
20 KiB
Go
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("<html><body>upstream said: " + echoed + "</body></html>")
|
||
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)
|
||
}
|
||
}
|