package weixin_ilink // 本文件锁定**出站媒体链的三项官方口径**(@tencent-weixin/openclaw-weixin 2.4.9): // // ① 上传用 **POST**(不是 PUT)—— src/cdn/cdn-upload.ts:42-46 // ② 下载凭据来自响应头 `x-encrypted-param` — src/cdn/cdn-upload.ts:61 // ③ item 用官方**嵌套**形状 src/messaging/send.ts:293-303/337-347/381-392 // // 三项各自都有 case;缺一项就等于「拿不到地址→上传→组装 item」这条链断在中间。 import ( "bytes" "context" "crypto/aes" "encoding/hex" "errors" "net/http" "net/http/httptest" "strconv" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // uploadCaptureServer 是假 iLink + 假 CDN:getuploadurl 给一条上传地址,CDN 上传按 // uploads 序列依次作答(用于数尝试次数),sendmessage 回 ret:0。 type uploadCaptureServer struct { srv *httptest.Server rec *reqRecorder attempts int // uploads 是每次上传尝试的裁决;用尽后沿用最后一条。 uploads []handlerResult // bodies 记录每次上传提交的密文。 bodies [][]byte } func newUploadCaptureServer(t *testing.T, uploads ...handlerResult) *uploadCaptureServer { t.Helper() s := &uploadCaptureServer{rec: &reqRecorder{}, uploads: uploads} s.srv = newJSONServer(t, s.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": s.srv.URL + "/cdn/up"})} case req.path == "/cdn/up": s.attempts++ s.bodies = append(s.bodies, req.body) i := s.attempts - 1 if i >= len(s.uploads) { i = len(s.uploads) - 1 } return s.uploads[i] 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} } }) return s } // okUpload 是「CDN 上传成功」的官方裁决:200 + 下载凭据头。 func okUpload(param string) handlerResult { return handlerResult{ status: http.StatusOK, body: []byte("ok"), headers: map[string]string{uploadParamHeader: param}, } } // sendMedia 让适配器把一条媒体投递出去,失败即 FATAL。 func sendMedia(t *testing.T, s *uploadCaptureServer, md adapter.Media) { t.Helper() ad := newConnectedAdapter(t, Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: "tok"}, s.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{md}}}, }); err != nil { t.Fatalf("SendMessage(media): %v", err) } } // mediaOfSentItem 取出 sendmessage 里那条媒体 item 的 `media` 子对象。 func mediaOfSentItem(t *testing.T, rec *reqRecorder, sub string) map[string]any { t.Helper() items := outboundItems(t, rec) if len(items) != 1 { t.Fatalf("item_list = %d 条, want 1", len(items)) } item, ok := items[0].(map[string]any) if !ok { t.Fatalf("item 不是对象:%+v", items[0]) } inner, ok := item[sub].(map[string]any) if !ok { t.Fatalf("item 缺少官方的 %s 嵌套(实际键:%v)", sub, item) } media, ok := inner["media"].(map[string]any) if !ok { t.Fatalf("%s 缺少 media 子对象:%+v", sub, inner) } return media } // TestOutboundMedia_ItemUsesOfficialNestedShape 覆盖 ③:item 必须是 // `_item.media{encrypt_query_param, aes_key, encrypt_type}` 的嵌套形状,且**不再** // 出现旧实现的平铺 cdn_ref/url/aes_key(那是逆向稿猜测,官方 send.ts 从不发)。 func TestOutboundMedia_ItemUsesOfficialNestedShape(t *testing.T) { t.Parallel() const param = "dl+param/file=" s := newUploadCaptureServer(t, okUpload(param)) payload := []byte("文件正文") sendMedia(t, s, adapter.Media{Kind: adapter.MediaFile, Data: payload, Name: "report.txt"}) items := outboundItems(t, s.rec) item := items[0].(map[string]any) for _, stale := range []string{"cdn_ref", "url", "aes_key", "file_name", "size", "mime"} { if _, found := item[stale]; found { t.Fatalf("item 仍带平铺键 %q(旧猜测形态):%+v", stale, item) } } media := mediaOfSentItem(t, s.rec, "file_item") if media["encrypt_query_param"] != param { t.Fatalf("encrypt_query_param = %v, want 上传响应头带回来的凭据 %q", media["encrypt_query_param"], param) } if media["encrypt_type"] != float64(encryptTypePacked) { t.Fatalf("encrypt_type = %v, want %d", media["encrypt_type"], encryptTypePacked) } // `len` 是**明文**体积的十进制字符串(官方 send.ts:390 `String(fileSize)`)。 fileItem := item["file_item"].(map[string]any) if fileItem["len"] != strconv.Itoa(len(payload)) { t.Fatalf("file_item.len = %v, want %q", fileItem["len"], strconv.Itoa(len(payload))) } if fileItem["file_name"] != "report.txt" { t.Fatalf("file_item.file_name = %v, want report.txt", fileItem["file_name"]) } // `aes_key` 必须是 base64(32 位十六进制串)(官方 send.ts:386),且它解出的密钥必须 // 正好能解开我们 POST 上去的那份密文 —— 形状对了、值对不上同样是坏链。 encoded, ok := media["aes_key"].(string) if !ok { t.Fatalf("aes_key 不是字符串:%v", media["aes_key"]) } key, err := parseMediaAesKey(encoded) if err != nil { t.Fatalf("aes_key 不符合官方两种编码之一:%v", err) } if len(s.bodies) != 1 { t.Fatalf("上传提交了 %d 次, want 1", len(s.bodies)) } plain, err := aesECBDecrypt(key, s.bodies[0]) if err != nil { t.Fatalf("item 上的 aes_key 解不开上传的密文:%v", err) } if !bytes.Equal(plain, payload) { t.Fatalf("解出的明文 = %q, want %q", plain, payload) } } // TestOutboundMedia_UploadUsesPost 覆盖 ①:上传必须是 POST。官方 cdn-upload.ts:42-46 // 用 fetch(url, {method:"POST"});旧实现用 PUT,真机上必然被 CDN 拒绝。 func TestOutboundMedia_UploadUsesPost(t *testing.T) { t.Parallel() s := newUploadCaptureServer(t, okUpload("p1")) sendMedia(t, s, adapter.Media{Kind: adapter.MediaImage, Data: []byte("jpeg"), Name: "a.jpg"}) uploads := s.rec.find(func(q recordedReq) bool { return q.path == "/cdn/up" }) if len(uploads) != 1 { t.Fatalf("CDN 上传请求 = %d 次, want 1", len(uploads)) } if uploads[0].method != http.MethodPost { t.Fatalf("CDN 上传方法 = %s, want POST", uploads[0].method) } if uploads[0].ctype != "application/octet-stream" { t.Fatalf("上传 Content-Type = %q, want application/octet-stream", uploads[0].ctype) } if puts := s.rec.find(func(q recordedReq) bool { return q.method == http.MethodPut }); len(puts) != 0 { t.Fatalf("出现了 %d 次 PUT,官方口径不使用 PUT", len(puts)) } } // TestOutboundMedia_DownloadParamComesFromResponseHeader 覆盖 ②:item 的 // encrypt_query_param 必须来自上传响应头 `x-encrypted-param`,**而不是**上传地址本身 // (旧实现把上传 URL 当引用填进去 —— 那是 CDN 的写入口,对方拿到也下不下来)。 func TestOutboundMedia_DownloadParamComesFromResponseHeader(t *testing.T) { t.Parallel() const param = "from-header-42" s := newUploadCaptureServer(t, okUpload(param)) sendMedia(t, s, adapter.Media{Kind: adapter.MediaImage, Data: []byte("jpeg"), Name: "a.jpg"}) media := mediaOfSentItem(t, s.rec, "image_item") if media["encrypt_query_param"] != param { t.Fatalf("encrypt_query_param = %v, want 响应头值 %q", media["encrypt_query_param"], param) } if media["encrypt_query_param"] == s.srv.URL+"/cdn/up" { t.Fatal("encrypt_query_param 是上传地址本身(写入口不能当下载引用)") } // 图片的体积字段是 mid_size,且必须是**密文**长度(官方 send.ts:301)。 imageItem := outboundItems(t, s.rec)[0].(map[string]any)["image_item"].(map[string]any) if imageItem["mid_size"] != float64(len(s.bodies[0])) { t.Fatalf("image_item.mid_size = %v, want 密文长度 %d", imageItem["mid_size"], len(s.bodies[0])) } // 官方出站**不**设 image_item.aeskey(那是入站优先分支用的),不得臆造。 if _, found := imageItem["aeskey"]; found { t.Fatalf("image_item 多了官方出站没有的 aeskey 字段:%+v", imageItem) } } // TestOutboundMedia_VideoItemSizeField 钉住视频的体积字段名与语义:`video_item.video_size` // = 密文长度(官方 send.ts:345)。 func TestOutboundMedia_VideoItemSizeField(t *testing.T) { t.Parallel() s := newUploadCaptureServer(t, okUpload("p2")) sendMedia(t, s, adapter.Media{Kind: adapter.MediaVideo, Data: []byte("mp4-bytes"), Name: "v.mp4"}) mediaOfSentItem(t, s.rec, "video_item") item := outboundItems(t, s.rec)[0].(map[string]any) videoItem := item["video_item"].(map[string]any) if videoItem["video_size"] != float64(len(s.bodies[0])) { t.Fatalf("video_item.video_size = %v, want 密文长度 %d", videoItem["video_size"], len(s.bodies[0])) } // 文件才有的字段不得出现在视频 item 上。 if _, found := videoItem["len"]; found { t.Fatalf("video_item 混入了文件字段 len:%+v", videoItem) } } // TestOutboundMedia_UploadWithoutParamHeaderIsHonest 覆盖 ④-a:200 但没有 // `x-encrypted-param` 头 ⇒ 必须**失败**,绝不能拿上传地址冒充下载凭据当成功。 // 官方 cdn-upload.ts:62-65 同样是「缺头即失败」。 func TestOutboundMedia_UploadWithoutParamHeaderIsHonest(t *testing.T) { t.Parallel() s := newUploadCaptureServer(t, handlerResult{status: http.StatusOK, body: []byte("ok")}) ad := newLoggingSendAdapter(t, s.srv, &bytes.Buffer{}) 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, s.rec) if len(items) != 1 || intOf(items[0].(map[string]any)["type"]) != itemText { t.Fatalf("item_list = %+v, want 只剩文本", items) } if got := items[0].(map[string]any)["text_item"].(map[string]any)["text"]; got != reply { t.Fatalf("发出的文本 = %q, want %q", got, reply) } if s.attempts != uploadMaxAttempts { t.Fatalf("上传尝试 = %d 次, want %d(缺头属于可重试失败)", s.attempts, uploadMaxAttempts) } sent := s.rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/sendmessage" }) if len(sent) == 0 { t.Fatal("文本消息没有发出去") } } // TestOutboundMedia_UploadClientErrorIsNotRetried 覆盖 ④-b:4xx 是客户端错误,重试徒劳 // (官方 cdn-upload.ts:47-52 立即抛出)。且服务端的 `x-error-message` **不进错误** // (M50 口径:它是上游自由文本面,与信封 message / Bot API description / OneBot msg // 同族;4xx 这个 status 已足够分类)—— 变异:把它拼回错误 ⇒ 本用例后半段立刻红。 func TestOutboundMedia_UploadClientErrorIsNotRetried(t *testing.T) { t.Parallel() const sentinel = "invalid content: 她刚才说的那句话" s := newUploadCaptureServer(t, handlerResult{ status: http.StatusBadRequest, body: []byte("ignored"), headers: map[string]string{"x-error-message": sentinel}, }) ad := newConnectedAdapter(t, Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: "tok"}, s.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 err == nil { t.Fatal("4xx 上传失败却报成功") } if strings.Contains(err.Error(), "她刚才说的那句话") { t.Fatalf("服务端的 x-error-message 进了错误:%v", err) } if !strings.Contains(err.Error(), "status 400") { t.Fatalf("剥掉服务端说明时把 status 一起丢了:%v", err) } if s.attempts != 1 { t.Fatalf("4xx 被重试了:上传尝试 %d 次, want 1", s.attempts) } } // TestOutboundMedia_UploadServerErrorIsRetried 覆盖官方重试口径:非 4xx 失败重试到成功, // 总尝试次数不超过 UPLOAD_MAX_RETRIES=3(官方 cdn-upload.ts:40-83)。 func TestOutboundMedia_UploadServerErrorIsRetried(t *testing.T) { t.Parallel() s := newUploadCaptureServer(t, handlerResult{status: http.StatusInternalServerError, body: []byte("boom")}, okUpload("recovered"), ) sendMedia(t, s, adapter.Media{Kind: adapter.MediaFile, Data: []byte("x"), Name: "1.txt"}) if s.attempts != 2 { t.Fatalf("上传尝试 = %d 次, want 2(先 500 再 200)", s.attempts) } media := mediaOfSentItem(t, s.rec, "file_item") if media["encrypt_query_param"] != "recovered" { t.Fatalf("重试成功后凭据 = %v, want recovered", media["encrypt_query_param"]) } } // TestUploadCiphertext_ExhaustsAttempts 钉住上限:一直 5xx 时最多尝试 // uploadMaxAttempts 次就诚实失败(不无限重试)。 func TestUploadCiphertext_ExhaustsAttempts(t *testing.T) { t.Parallel() s := newUploadCaptureServer(t, handlerResult{status: http.StatusBadGateway, body: []byte("nope")}) ad := newConnectedAdapter(t, Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: "tok"}, s.srv) _, err := ad.uploadCiphertext(context.Background(), s.srv.URL+"/cdn/up", []byte("cipher")) if err == nil { t.Fatal("一直 5xx 却报成功") } if s.attempts != uploadMaxAttempts { t.Fatalf("上传尝试 = %d 次, want %d", s.attempts, uploadMaxAttempts) } } // TestEncodeMediaAesKey_RoundTripsThroughInboundParser 是形状与取值的**互逆**用例: // 出站写出的 `aes_key` 必须能被本包入站的官方口径解析回同一把密钥(对方按官方解码, // 我们按官方编码,两边不能各说各话)。 func TestEncodeMediaAesKey_RoundTripsThroughInboundParser(t *testing.T) { t.Parallel() key := keyOf(0x5A) encoded := encodeMediaAesKey(key) // 官方编码 = base64(32 位十六进制串):先验形状。 raw, err := decodeBase64Flexible(encoded) if err != nil { t.Fatalf("decodeBase64Flexible: %v", err) } if len(raw) != 32 || !isHexASCII(raw) { t.Fatalf("编码后不是 32 位十六进制串的 base64:%q -> %q", encoded, raw) } got, err := parseMediaAesKey(encoded) if err != nil { t.Fatalf("parseMediaAesKey: %v", err) } if !bytes.Equal(got, key) { t.Fatalf("往返后密钥 = %x, want %x", got, key) } if hex.EncodeToString(got) != hex.EncodeToString(key) { t.Fatal("往返后十六进制形态不一致") } } // TestUploadCiphertext_MissingHeaderErrorIsIdentifiable 钉住可判定性:缺凭据头必须以 // ErrNoUploadParam 上报,而不是笼统的字符串错误(上层要能据此区分原因)。 func TestUploadCiphertext_MissingHeaderErrorIsIdentifiable(t *testing.T) { t.Parallel() s := newUploadCaptureServer(t, handlerResult{status: http.StatusOK, body: []byte("ok")}) ad := newConnectedAdapter(t, Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: "tok"}, s.srv) _, err := ad.uploadCiphertext(context.Background(), s.srv.URL+"/cdn/up", []byte("cipher")) if !errors.Is(err, ErrNoUploadParam) { t.Fatalf("err = %v, want ErrNoUploadParam", err) } if errors.Is(err, ErrNoUploadURL) || errors.Is(err, ErrSessionRejected) { t.Fatalf("缺凭据头被误判成别的失败:%v", err) } } // TestNativeMediaItem_VoiceHasNoInventedSizeField 钉住「不臆造」:官方没有出站语音的 // 实现(src/messaging/send-media.ts:32-58 只路由视频/图片/文件),因此语音 item 只给 // `voice_item.media`,不编造体积字段。 func TestNativeMediaItem_VoiceHasNoInventedSizeField(t *testing.T) { t.Parallel() key := keyOf(0x7B) item := nativeMediaItem(adapter.Media{Kind: adapter.MediaVoice, Name: "v.silk"}, uploadedMedia{ downloadParam: "p", aesKey: key, rawSize: 3, cipherSize: 16, }) if item["type"] != itemVoice { t.Fatalf("type = %v, want %d", item["type"], itemVoice) } voiceItem, ok := item["voice_item"].(map[string]any) if !ok { t.Fatalf("缺少 voice_item:%+v", item) } if _, found := voiceItem["media"]; !found { t.Fatalf("voice_item 缺少 media:%+v", voiceItem) } for _, invented := range []string{"mid_size", "video_size", "len"} { if _, found := voiceItem[invented]; found { t.Fatalf("语音 item 臆造了体积字段 %q:%+v", invented, voiceItem) } } if len(item) != 2 { t.Fatalf("语音 item 顶层键 = %v, want 只有 type + voice_item", item) } } // TestNativeMediaItem_EncryptsNothingAboutCredentials 是红线自查:item 里绝不能出现 // bot_token 或上传地址本身 —— 唯一允许出现的凭据是 CDN 的下载参数与本次随机密钥。 func TestNativeMediaItem_EncryptsNothingAboutCredentials(t *testing.T) { t.Parallel() key := make([]byte, aes.BlockSize) for i := range key { key[i] = byte(i) } item := nativeMediaItem(adapter.Media{Kind: adapter.MediaFile, Name: "1.txt"}, uploadedMedia{ downloadParam: "param-only", aesKey: key, rawSize: 1, cipherSize: 16, }) fileItem := item["file_item"].(map[string]any) media := fileItem["media"].(map[string]any) if len(media) != 3 { t.Fatalf("media 键 = %v, want 恰好 encrypt_query_param/aes_key/encrypt_type", media) } if _, found := media["full_url"]; found { t.Fatal("官方出站不设 full_url,不得臆造") } }