Files
Cyrene_v2/plugins/channels/weixin_ilink/send_media_failure_test.go
T
AskaEth 6cb41a8794
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(weixin_ilink): 入站按条密钥 + 出站上传地址(真机双 bug)
Bug A(入站取不到字节)真因两层:① 引用位置错——真实在
`file_item.media.encrypt_query_param`,旧 mediaOf 只扫平铺 url/cdn_url;
② 密钥来源错——官方口径是**随每条附件下发**的 `*_item.media.aes_key`
(图片优先 `image_item.aeskey`),旧代码却把整个 bot_token 当 base64 取尾 16 字节,
而真机 token 形如 `<id>@im.bot:<38hex>`,第 12 字符 `@` 处即非法。
(对照证据:官方包 @tencent-weixin/openclaw-weixin 2.4.9 源码,只读未入库;
design/08 §3.3 的「token base64 尾 16 字节」据此判定为错误口径,待文档更正。)
Bug B(出站):① sendToOne 遇媒体构建失败直接 return ⇒ 连文本一起丢(改为逐条跳过
+ 带来源 WARN;全部媒体失败仍发文本,无文本且全挂才诚实报错);② getUploadURL 发空
body 且只认 4 个猜测键 ⇒ 补齐官方请求体字段 + 依次认 upload_full_url / upload_param。
测试:media_key_test(6 条,真机形态夹具 + 转义钉住)+ send_media_failure_test(6 条)。
变异 6 条(M-A1/A2/A3/B1/B3a/B3b)均先确认变异体可编译、贴红行、还原复绿。
诚实边界:出站媒体**仍发不出去**(PUT vs 官方 POST、响应头 x-encrypted-param、
nativeMediaItem 嵌套形状三项未做)—— 故意只修到「拿到上传地址」,不扩大切片。
2026-10-04 22:33:41 +08:00

331 lines
14 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 路径)。
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 作答,上传成功,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.MethodPut:
return handlerResult{status: http.StatusOK, body: []byte("ok")}
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}
}
})
}
// 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, "name=1.txt") {
t.Fatalf("告警缺少 kind/name:\n%s", 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)
}
puts := rec.find(func(q recordedReq) bool { return q.method == http.MethodPut })
if len(puts) != 0 {
t.Fatalf("上传地址缺失时不该发起上传:%d 次", len(puts))
}
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.MethodPut && req.path == "/cdn/up":
return handlerResult{status: http.StatusOK, body: []byte("ok")}
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.MethodPut && req.path == "/upload":
return handlerResult{status: http.StatusOK, body: []byte("ok")}
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.method == http.MethodPut })
if len(puts) != 1 {
t.Fatalf("上传请求 = %d 次, want 1", len(puts))
}
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 必须记进被拒状态")
}
})
}