Merge branch 'feat/b2d-weixinname-send-md-name-redact-and-l' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-26 22:11:58 +08:00
commit b626b3f5c0
3 files changed
+84 -13

No files matched your search

@@ -1,7 +1,6 @@
package weixin_ilink
import (
"bytes"
"context"
"fmt"
"log/slog"
@@ -148,19 +147,30 @@ func TestStartLogin_WaitingPastWindowReMints(t *testing.T) {
// TestLogin_LogRedaction 验证扫码登录路径只记录已脱敏的标识:可渲染的二维码 URL 与
// bot id 会出现,但原始 qrcode 标识符、bot_token 以及主人的 user_id 绝不写入。
//
// ⚠️ 日志缓冲必须是**并发安全**的 syncBuffer(M56 修掉既有 DATA RACE):写侧是
// StartLogin 起的登录 goroutine(completeLogin 里的 `login confirmed`),读侧是测试
// goroutine 的 buf.String(),两侧之间没有 happens-before 边 —— 裸 bytes.Buffer 在
// -race 下必报。等待条件也必须等**那一行日志**落盘,而不只是 ad.IsConnected():
// completeLogin 先在 a.mu 下置 connected、**之后**才打日志,只等 IsConnected 会
// 偶发地读到还没有 `login confirmed` 的缓冲(同一处竞态的另一种表现:抖动)。
func TestLogin_LogRedaction(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
statusCalls := 0
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
var buf syncBuffer
rec := &reqRecorder{}
// 用 reqRecorder 数状态轮询次数(它自带互斥),而不是在处理器里 += 一个裸 int:
// 处理器跑在 httptest 的服务端 goroutine 上,任何无保护的共享计数器都是同一类
// 竞态的下一个落点。
srv := newJSONServer(t, rec, func(req recordedReq) handlerResult {
switch req.path {
case "/ilink/bot/get_bot_qrcode":
return handlerResult{body: jsonBody(map[string]any{
"qrcode": "qr-redact", "qrcode_img_content": "https://img/redact",
})}
case "/ilink/bot/get_qrcode_status":
statusCalls++
if statusCalls == 1 {
// rec.add 在处理器被调用之前完成,所以这里数到的已包含本次请求。
calls := len(rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_qrcode_status" }))
if calls == 1 {
return handlerResult{body: jsonBody(map[string]any{"status": "scaned"})}
}
return handlerResult{body: jsonBody(map[string]any{
@@ -180,7 +190,9 @@ func TestLogin_LogRedaction(t *testing.T) {
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
waitFor(t, func() bool { return ad.IsConnected() })
waitFor(t, func() bool {
return ad.IsConnected() && strings.Contains(buf.String(), "login confirmed")
})
logs := buf.String()
for _, want := range []string{
+17 -4
View File
@@ -93,8 +93,9 @@ func (a *Adapter) sendGuard() error {
//
// **单条媒体的失败绝不连累文本**:媒体只是附件,回复的主体是文字。真机实测
// (2026-10-04)就是「媒体项构建失败 ⇒ 整条回复被判失败 ⇒ 她的中文文本一起没了」。
// 因此这里逐条构建,失败的那条跳过并留一条带来源的 Warn(kind/name/原因),文本项
// 照常发出;全部媒体都失败也仍然发文本。失败必须可诊断,但绝不等于整条回复失败。
// 因此这里逐条构建,失败的那条跳过并留一条带来源的 Warn(kind/size/序号/原因),
// 文本项照常发出;全部媒体都失败也仍然发文本。失败必须可诊断,但绝不等于整条回复
// 失败。
func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedMessage) error {
if peer == "" {
return ErrNoContextToken
@@ -109,14 +110,26 @@ func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedM
items = append(items, nativeTextItem(m.Content))
}
var mediaErr error
for _, md := range m.Media {
for i, md := range m.Media {
item, err := a.buildMediaItem(ctx, peer, md)
if err != nil {
if mediaErr == nil {
mediaErr = err
}
// 🔴 `md.Name` **整字段不进日志**(M56)。它是**用户侧自由文本**:出站媒体名
// 来自调用方给的 adapter.Media.Name,入站侧就是用户自己起的文件名,可以是
// 「身份证-张三.jpg」这类内容 —— 与同一行的 peer/kind/size 不是一类东西。
//
// 为什么删字段、而不是打码或截断:包内可复用的打码(trace.RedactTextLimited)
// 是**按形状**认凭据的,对「长得像人话的文件名」完全无效;截断则必然留下前 N
// 个字符,而中文文件名的头几个字往往就是全部有意义的部分 —— 两种修法都只是
// 把泄露面缩小一点,并没有关上。而这个字段对排障**零贡献**:失败原因在 err
// 里,出问题的是哪一条由 kind + size + 下标(index)唯一确定。删字段因此是
// 严格更强、又不损失诊断力的选择。包内已有同一口径的先例:入站取证行
// (inbound_debug.go 的 inboundMediaShape)同样明确「正文、文件名、URL 原串
// 与一切密钥都不取」—— 日志面从不承载用户侧的正文/文件名,两个方向一致。
a.log.Warn("outbound media item skipped",
"peer", peer, "kind", md.Kind, "name", md.Name, "size", md.Size, "err", err)
"peer", peer, "kind", md.Kind, "size", md.Size, "index", i, "err", err)
continue
}
items = append(items, item)
@@ -6,6 +6,9 @@ package weixin_ilink
// 真机现场是她的中文文本本身完好,却因为一个媒体项构建失败而整条没发出去;
// B3 getuploadurl 的请求必须带上官方字段、响应必须认官方键名,且「键缺失」
// 与「会话被拒」必须分开报(后者已有 markRejected 路径)。
//
// M56 又追加了一条**日志内容**边界:失败告警里绝不出现 md.Name(它是用户侧自由
// 文本),可诊断性改由 kind/size/index/err 承担。
import (
"bytes"
@@ -117,8 +120,51 @@ func TestSendMessage_MediaFailureStillSendsText(t *testing.T) {
if !strings.Contains(logs, "source="+source) {
t.Fatalf("告警没有标注来源:\n%s", logs)
}
if !strings.Contains(logs, "kind=file") || !strings.Contains(logs, "name=1.txt") {
t.Fatalf("告警缺少 kind/name:\n%s", logs)
if !strings.Contains(logs, "kind=file") || !strings.Contains(logs, "index=0") {
t.Fatalf("告警缺少 kind/序号(媒体名的位置由它们承担):\n%s", logs)
}
}
// TestSendMessage_MediaFailureLogOmitsUserFileName 钉住失败告警的**内容边界**(M56):
// md.Name 是用户侧自由文本(入站侧就是用户自己起的文件名),所以失败日志里一个字都
// 不留 —— 连截断后的前缀也不行,断言因此拿「文件名的开头」当哨兵,把「截断到 N 个
// 字符」这类半吊子修法一起打红。可诊断性由 kind/size/index/err 承担,这里也逐个断言:
// 否则「把整行日志删掉」同样能让泄露消失。
func TestSendMessage_MediaFailureLogOmitsUserFileName(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := mediaSendServer(t, rec, handlerResult{body: jsonBody(map[string]any{})})
var buf bytes.Buffer
ad := newLoggingSendAdapter(t, srv, &buf)
ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
// 用户侧自由文本的典型形态:中文姓名 + 证件号。两者都不该进日志。
const userFileName = "身份证正面-张三-110101199003071234.jpg"
payload := []byte("user-bytes")
if err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Content: "文本照发",
Media: []adapter.Media{{
Kind: adapter.MediaFile, Data: payload, Name: userFileName, Size: int64(len(payload)),
}},
}},
}); err != nil {
t.Fatalf("媒体失败连累了整条回复:%v", err)
}
logs := buf.String()
// 先确认告警本身还在:否则「没泄露」只是因为什么都没记。
for _, want := range []string{"outbound media item skipped", "kind=file", "size=10", "index=0", "err="} {
if !strings.Contains(logs, want) {
t.Fatalf("失败告警缺少 %q(诊断力不能被一起删掉):\n%s", want, logs)
}
}
// 泄露面:整名、名字开头、证件号 —— 任一段出现即红。
for _, leak := range []string{userFileName, "身份证", "110101199003071234"} {
if strings.Contains(logs, leak) {
t.Fatalf("用户文件名泄露 %q:\n%s", leak, logs)
}
}
}