397 lines
17 KiB
Go
397 lines
17 KiB
Go
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 必须记进被拒状态")
|
||
}
|
||
})
|
||
}
|