Merge branch 'feat/b2d-weixinname-send-md-name-redact-and-l' into dev
This commit is contained in:
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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user