Files
Cyrene_v2/plugins/channels/weixin_ilink/send_media_failure_test.go
T

397 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package weixin_ilink
// 本文件锁定出站媒体的两条真机纪律(2026-10-04 实机缺陷):
//
// B1 单条媒体项构建/上传失败**不得**把整条回复(含文本)判失败 ——
// 真机现场是她的中文文本本身完好,却因为一个媒体项构建失败而整条没发出去;
// B3 getuploadurl 的请求必须带上官方字段、响应必须认官方键名,且「键缺失」
// 与「会话被拒」必须分开报(后者已有 markRejected 路径)。
//
// M56 又追加了一条**日志内容**边界:失败告警里绝不出现 md.Name(它是用户侧自由
// 文本),可诊断性改由 kind/size/index/err 承担。
import (
"bytes"
"context"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// mediaSendServer 是假 iLink:getuploadurl 按 upload 作答,**CDN 上传按官方口径回
// 200 + `x-encrypted-param` 头**(POST),sendmessage 回 ret:0。
func mediaSendServer(t *testing.T, rec *reqRecorder, upload handlerResult) *httptest.Server {
t.Helper()
return newJSONServer(t, rec, func(req recordedReq) handlerResult {
switch {
case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl":
return upload
case req.method == http.MethodPost && req.path == "/cdn/up":
return handlerResult{
status: http.StatusOK,
body: []byte("ok"),
headers: map[string]string{uploadParamHeader: downloadParamFixture},
}
case req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage":
return handlerResult{body: jsonBody(map[string]any{"ret": 0})}
default:
return handlerResult{status: http.StatusNotFound}
}
})
}
// downloadParamFixture 是假 CDN 回填的下载凭据(真实值由服务端生成,测试只关心它被
// 原样带进 item 的 media.encrypt_query_param)。
const downloadParamFixture = "dl+param/1="
// newLoggingSendAdapter 装配一个把日志写进 buf 的已连接适配器,用来断言失败**不静默**。
func newLoggingSendAdapter(t *testing.T, srv *httptest.Server, buf *bytes.Buffer) *Adapter {
t.Helper()
log := logger.WithSource(
slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), source)
ad, err := New(Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv.Client(), log)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
return ad
}
// outboundItems 返回第一个 sendmessage 请求的 item_list。
func outboundItems(t *testing.T, rec *reqRecorder) []any {
t.Helper()
items, ok := getSendBody(t, rec)["msg"].(map[string]any)["item_list"].([]any)
if !ok {
t.Fatal("sendmessage 没有 item_list")
}
return items
}
// TestSendMessage_MediaFailureStillSendsText 是 **Bug B1 的回归用例**:媒体项构建失败时,
// 文本必须照常发出,且失败要留下带来源的 WARN。
func TestSendMessage_MediaFailureStillSendsText(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
// getuploadurl 回一个「什么都没有」的空壳:媒体项必然构建失败。
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 reply = "这是一条正常的中文回复"
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Content: reply,
Media: []adapter.Media{{Kind: adapter.MediaFile, Data: []byte("x"), Name: "1.txt"}},
}},
})
if err != nil {
t.Fatalf("媒体失败连累了整条回复:%v", err)
}
items := outboundItems(t, rec)
if len(items) != 1 {
t.Fatalf("item_list = %d 条, want 1(失败的媒体被跳过、文本照发)", len(items))
}
item, _ := items[0].(map[string]any)
if intOf(item["type"]) != itemText {
t.Fatalf("item type = %v, want %d", item["type"], itemText)
}
if txt := item["text_item"].(map[string]any)["text"]; txt != reply {
t.Fatalf("发出的文本 = %q, want %q", txt, reply)
}
// 失败必须可诊断:一条带来源、带原因的 WARN。
logs := buf.String()
if !strings.Contains(logs, "level=WARN") || !strings.Contains(logs, "outbound media item skipped") {
t.Fatalf("媒体失败没有留下 WARN:\n%s", logs)
}
if !strings.Contains(logs, "source="+source) {
t.Fatalf("告警没有标注来源:\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)
}
}
}
// TestSendMessage_AllMediaFailWithoutTextIsReported 钉住反向边界:一条文本都没有、媒体
// 又全挂 ⇒ **什么都没发出去**,不能报成功(诚实失败,而不是静默丢弃)。
func TestSendMessage_AllMediaFailWithoutTextIsReported(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := mediaSendServer(t, rec, handlerResult{body: jsonBody(map[string]any{})})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Media: []adapter.Media{{Kind: adapter.MediaFile, Data: []byte("x"), Name: "1.txt"}},
}},
})
if !errors.Is(err, ErrNoUploadURL) {
t.Fatalf("err = %v, want ErrNoUploadURL", err)
}
if reqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/sendmessage" }); len(reqs) != 0 {
t.Fatalf("没有任何可投递内容却发出了 %d 次 sendmessage", len(reqs))
}
}
// TestGetUploadURL_RequestCarriesProtocolFields 钉住「returned no url」的真根因:旧实现
// 发的是**空 body**,而官方 GetUploadUrlReq 要求
// filekey/media_type/to_user_id/rawsize/rawfilemd5/filesize/no_need_thumb/aeskey。
// 没有这些字段,服务端只会回一个空壳。
func TestGetUploadURL_RequestCarriesProtocolFields(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := mediaSendServer(t, rec, handlerResult{body: jsonBody(map[string]any{})})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
payload := []byte("fake-jpeg-bytes")
// 媒体仍会按 B1 被跳过(这里不关心结果),我们只看它发出的 getuploadurl 请求。
_ = ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Media: []adapter.Media{{Kind: adapter.MediaImage, Data: payload, Name: "a.jpg"}},
}},
})
reqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/getuploadurl" })
if len(reqs) != 1 {
t.Fatalf("getuploadurl 请求 = %d 次, want 1", len(reqs))
}
body := reqs[0].bodyJSON()
if body == nil {
t.Fatal("getuploadurl 请求体不是 JSON")
}
fileKey, _ := body["filekey"].(string)
if len(fileKey) != 2*fileKeyBytes || !isHexASCII([]byte(fileKey)) {
t.Fatalf("filekey = %q, want %d 位十六进制", fileKey, 2*fileKeyBytes)
}
// ⚠️ 这里必须是 getuploadurl 的 media_type 码(图片=1),而不是 item 的 type 码(图片=2)。
if got := intOf(body["media_type"]); got != uploadMediaImage {
t.Fatalf("media_type = %v, want %d(与 item 码不是一套)", body["media_type"], uploadMediaImage)
}
if body["to_user_id"] != "u-1" {
t.Fatalf("to_user_id = %v, want u-1", body["to_user_id"])
}
if got := int64(intOf(body["rawsize"])); got != int64(len(payload)) {
t.Fatalf("rawsize = %d, want %d", got, len(payload))
}
if body["rawfilemd5"] != md5Hex(payload) {
t.Fatalf("rawfilemd5 = %v, want %s", body["rawfilemd5"], md5Hex(payload))
}
cipherSize := int64(intOf(body["filesize"]))
if cipherSize <= int64(len(payload)) || cipherSize%16 != 0 {
t.Fatalf("filesize = %d, want PKCS#7 填充后的密文长度(> %d 且为 16 的倍数)", cipherSize, len(payload))
}
if body["no_need_thumb"] != true {
t.Fatalf("no_need_thumb = %v, want true(我们不上传缩略图)", body["no_need_thumb"])
}
// aeskey 必须是真正用于加密的那把:拿它去解上传的密文能还原原文。
rawKey, err := hex.DecodeString(body["aeskey"].(string))
if err != nil {
t.Fatalf("aeskey 不是十六进制:%v", err)
}
// 上传地址缺失时**一次上传请求都不该发出**(只应有那一次 getuploadurl)。
if reqs := rec.find(func(q recordedReq) bool { return q.method == http.MethodPost }); len(reqs) != 1 {
t.Fatalf("POST 请求 = %d 次, want 1(只有 getuploadurl;地址缺失就不该上传)", len(reqs))
}
if len(rawKey) != 16 {
t.Fatalf("aeskey = %d 字节, want 16", len(rawKey))
}
}
// TestOutboundMedia_UsesUploadFullURL 覆盖官方 GetUploadUrlResp 的主字段
// `upload_full_url`:旧实现只认 upload_url/url/... 这几个猜测名,拿到它也会判「没地址」。
func TestOutboundMedia_UsesUploadFullURL(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
var srv *httptest.Server
srv = newJSONServer(t, rec, func(req recordedReq) handlerResult {
switch {
case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl":
return handlerResult{body: jsonBody(map[string]any{"upload_full_url": srv.URL + "/cdn/up"})}
case req.method == http.MethodPost && req.path == "/cdn/up":
return handlerResult{
status: http.StatusOK,
body: []byte("ok"),
headers: map[string]string{uploadParamHeader: downloadParamFixture},
}
case req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage":
return handlerResult{body: jsonBody(map[string]any{"ret": 0})}
default:
return handlerResult{status: http.StatusNotFound}
}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
ad.recordInbound("tok-m", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
if err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Media: []adapter.Media{{Kind: adapter.MediaImage, Data: []byte("bytes"), Name: "a.jpg"}},
}},
}); err != nil {
t.Fatalf("SendMessage(media, upload_full_url): %v", err)
}
items := outboundItems(t, rec)
if len(items) != 1 || intOf(items[0].(map[string]any)["type"]) != itemImage {
t.Fatalf("item_list = %+v, want 一条图片 item", items)
}
}
// TestOutboundMedia_BuildsUploadURLFromUploadParam 覆盖 `upload_param`:官方口径是把它
// 拼到 CDN 根下的 `/upload?encrypted_query_param=…&filekey=…`,且 filekey 必须与
// getuploadurl 请求里发出的那一个一致。
func TestOutboundMedia_BuildsUploadURLFromUploadParam(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
var srv *httptest.Server
srv = newJSONServer(t, rec, func(req recordedReq) handlerResult {
switch {
case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl":
return handlerResult{body: jsonBody(map[string]any{"upload_param": "ab+cd="})}
case req.method == http.MethodPost && req.path == "/upload":
return handlerResult{
status: http.StatusOK,
body: []byte("ok"),
headers: map[string]string{uploadParamHeader: downloadParamFixture},
}
case req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage":
return handlerResult{body: jsonBody(map[string]any{"ret": 0})}
default:
return handlerResult{status: http.StatusNotFound}
}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
ad.recordInbound("tok-m", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
if err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Media: []adapter.Media{{Kind: adapter.MediaImage, Data: []byte("bytes"), Name: "a.jpg"}},
}},
}); err != nil {
t.Fatalf("SendMessage(media, upload_param): %v", err)
}
puts := rec.find(func(q recordedReq) bool { return q.path == "/upload" })
if len(puts) != 1 {
t.Fatalf("上传请求 = %d 次, want 1", len(puts))
}
if puts[0].method != http.MethodPost {
t.Fatalf("上传方法 = %s, want POST(官方口径)", puts[0].method)
}
if !strings.Contains(puts[0].query, "encrypted_query_param=ab%2Bcd%3D") {
t.Fatalf("上传查询串 = %q, want 转义后的 encrypted_query_param", puts[0].query)
}
param, err := url.ParseQuery(puts[0].query)
if err != nil {
t.Fatalf("上传查询串不合法:%v", err)
}
if got := param.Get("encrypted_query_param"); got != "ab+cd=" {
t.Fatalf("服务端解出的 upload_param = %q, want ab+cd=", got)
}
upReqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/getuploadurl" })
body := upReqs[0].bodyJSON()
if got, want := param.Get("filekey"), body["filekey"]; got != want {
t.Fatalf("上传 URL 里的 filekey = %q, 与 getuploadurl 请求里的 %v 不一致", got, want)
}
}
// TestGetUploadURL_DistinguishesMissingAddressFromSessionRejected 钉住两类失败的分野:
// 键缺失是协议字段对不上(ErrNoUploadURL,不改变凭据状态),errcode=-14 是凭据被拒
// (ErrSessionRejected,必须记进被拒状态、要求重新扫码)。
func TestGetUploadURL_DistinguishesMissingAddressFromSessionRejected(t *testing.T) {
t.Parallel()
req := uploadRequest{peer: "u-1", mediaType: uploadMediaFile, aesKey: make([]byte, 16)}
t.Run("address missing", func(t *testing.T) {
t.Parallel()
srv := mediaSendServer(t, &reqRecorder{}, handlerResult{body: jsonBody(map[string]any{})})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
_, err := ad.getUploadURL(context.Background(), req)
if !errors.Is(err, ErrNoUploadURL) {
t.Fatalf("err = %v, want ErrNoUploadURL", err)
}
if errors.Is(err, ErrSessionRejected) {
t.Fatal("键缺失被误判成凭据被拒")
}
if required, _ := ad.RescanRequired(); required {
t.Fatal("键缺失不该要求重新扫码")
}
})
t.Run("session rejected", func(t *testing.T) {
t.Parallel()
srv := mediaSendServer(t, &reqRecorder{}, handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
_, err := ad.getUploadURL(context.Background(), req)
if !errors.Is(err, ErrSessionRejected) {
t.Fatalf("err = %v, want ErrSessionRejected", err)
}
if errors.Is(err, ErrNoUploadURL) {
t.Fatal("凭据被拒被降级成「没返回地址」")
}
if required, _ := ad.RescanRequired(); !required {
t.Fatal("errcode=-14 必须记进被拒状态")
}
})
}