package harness import ( "strings" "testing" ) func TestRedactSecrets(t *testing.T) { cases := []struct { in string wantN int mustGone string // 脱敏后不应再出现的子串 }{ {"我的 key 是 sk-912cf85b16d04b22bcb95f4576423bfb 别外传", 1, "sk-912cf85b16d04b22bcb95f4576423bfb"}, {"token: AKIAIOSFODNN7EXAMPLE", 1, "AKIAIOSFODNN7EXAMPLE"}, {"Authorization: Bearer abcdef0123456789ABCDEF", 1, "abcdef0123456789ABCDEF"}, {"jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcd1234 完事", 1, "eyJhbGciOiJIUzI1NiJ9"}, } for _, c := range cases { out, n := RedactSecrets(c.in) if n != c.wantN { t.Errorf("RedactSecrets(%q) 命中数=%d want %d", c.in, n, c.wantN) } if strings.Contains(out, c.mustGone) { t.Errorf("脱敏后仍含密钥: %q", out) } if !strings.Contains(out, redactMark) { t.Errorf("应含脱敏标记: %q", out) } } } func TestRedactSecrets_Clean(t *testing.T) { in := "这是一段正常的回答,介绍杭州西湖的历史与十景。" out, n := RedactSecrets(in) if n != 0 || out != in { t.Errorf("正常文本不应被改动: n=%d out=%q", n, out) } } // PII:手机号 / 邮箱 / 身份证应被脱敏。 func TestRedactSecrets_PII(t *testing.T) { cases := []struct { in string mustGone string }{ {"联系我 13812345678 谢谢", "13812345678"}, {"邮箱 zhang.san@example.com 收", "zhang.san@example.com"}, {"身份证 11010519900307123X 备案", "11010519900307123X"}, } for _, c := range cases { out, n := RedactSecrets(c.in) if n != 1 { t.Errorf("RedactSecrets(%q) 命中=%d want 1", c.in, n) } if strings.Contains(out, c.mustGone) { t.Errorf("脱敏后仍含 PII: %q", out) } } } // 不应误伤:更长数字串里的合法 11 位子串不该被吞,普通词不该当邮箱。 func TestRedactSecrets_NoFalsePositive(t *testing.T) { for _, in := range []string{"订单号 138123456789012", "用户名 a@b(非邮箱)", "数量 12345"} { if out, n := RedactSecrets(in); n != 0 { t.Errorf("不应脱敏 %q,却命中 %d → %q", in, n, out) } } } // streamAll 把分片喂给 StreamRedactor,拼回消费端看到的完整文本与命中数。 func streamAll(chunks ...string) (string, int) { r := NewStreamRedactor() var sb strings.Builder for _, c := range chunks { sb.WriteString(r.Push(c)) } sb.WriteString(r.Flush()) return sb.String(), r.Hits() } // 跨分片:密钥被切成两片,逐片都不单独命中正则,流式脱敏须靠缓冲拼接捕获。 func TestStreamRedactor_CrossChunkSecret(t *testing.T) { // "sk-912cf85b" + "16d04b22bcb95f4576423bfb":第一片仅 8 位 body(<16 不命中),第二片无前缀。 out, n := streamAll("我的 key 是 sk-912cf85b", "16d04b22bcb95f4576423bfb 别外传") if strings.Contains(out, "sk-912cf85b16d04b22bcb95f4576423bfb") { t.Errorf("跨分片密钥漏检: %q", out) } if n != 1 { t.Errorf("应脱敏 1 处,got %d (%q)", n, out) } if !strings.Contains(out, redactMark) { t.Errorf("应含脱敏标记: %q", out) } } // 跨分片 + 逐字符流(最严苛):JWT 一个字符一片,仍须完整捕获。 func TestStreamRedactor_CharByChar(t *testing.T) { secret := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcd1234" full := "令牌:" + secret + ",妥善保管" chunks := make([]string, 0, len(full)) for _, r := range full { chunks = append(chunks, string(r)) } out, n := streamAll(chunks...) if strings.Contains(out, secret) { t.Errorf("逐字符流 JWT 漏检: %q", out) } if n != 1 { t.Errorf("应脱敏 1 处,got %d (%q)", n, out) } } // 回归:密钥 body 跨片,且第一片恰好凑够最短长度(16)。贪婪正则会提前脱敏半截、剩余明文泄漏。 // 切点须在原文上定、勿切断完整匹配 → 整条密钥完整脱敏,无碎片。 func TestStreamRedactor_NoFragmentLeak(t *testing.T) { out, n := streamAll("凭据 sk-ABCD1234efgh5678", "ijkl90mnop 完毕") if strings.Contains(out, "ijkl90mnop") { t.Errorf("密钥碎片泄漏: %q", out) } if strings.Contains(out, "sk-ABCD1234efgh5678") { t.Errorf("密钥头部泄漏: %q", out) } if n != 1 { t.Errorf("应脱敏 1 处,got %d (%q)", n, out) } } // 完整匹配落在尾窗内(身份证后仅跟少量文字):切点须整体并入暂留,不可从中切断。 func TestStreamRedactor_MatchInTailWindow(t *testing.T) { out, n := streamAll("身份证 11010519900307123X 完") if strings.Contains(out, "11010519900307123X") { t.Errorf("尾窗内身份证漏检: %q", out) } if n != 1 { t.Errorf("应脱敏 1 处,got %d (%q)", n, out) } } // 干净文本逐片流入,消费端拼回必须与原文逐字节一致(无乱码、无丢字、无暂留残留)。 func TestStreamRedactor_CleanReconstruct(t *testing.T) { full := "杭州西湖十景:苏堤春晓、曲院风荷、平湖秋月、断桥残雪,四季皆宜。" chunks := make([]string, 0) rs := []rune(full) for i := 0; i < len(rs); i += 2 { // 每 2 个中文字一片,制造多字节边界压力 end := i + 2 if end > len(rs) { end = len(rs) } chunks = append(chunks, string(rs[i:end])) } out, n := streamAll(chunks...) if out != full { t.Errorf("干净文本未原样重建:\n got %q\nwant %q", out, full) } if n != 0 { t.Errorf("干净文本不应脱敏,got %d", n) } }