信封路径(非 2xx + 信封体、2xx + code!=0)此前把上游 message 经 RedactText 拼进 error,而上游会在 message 里回显被拒的请求正文 —— 她的原话因此进了错误 与调用方日志(M49 探针实测:code 1: invalid content: 她刚才说的那句话 ...)。 - envelope message 改为**默认不带**(新 opt-in WithEnvelopeMessage(),与 trace.UpstreamError 的 message 同一口径);code 与上游 status 保留, errors.Is 的 ErrAPI / trace.ErrUpstream 链不变。 - opt-in 下统一走单一清洗 upstreamText:压平空白 + trace.RedactTextLimited, 上限 40 rune(比 trace 的 128 短:错误里那句话是提示,不是摘要)。 - plugins/channels 同族出口一并收口(扫过全部,命中 4 处): telegram description、qq_obv11 msg(错误 + logEchoFailure 日志)、 weixin_ilink CDN x-error-message;qq 的 status 字段改走 trace.ErrorToken 令牌守卫。被剥掉的一律由 status / error_code / retcode 承担分类。 每条新增/改写用例都做过变异验证(把字段拼回去即变红)。
428 lines
17 KiB
Go
428 lines
17 KiB
Go
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 必须是
|
||
// `<kind>_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,不得臆造")
|
||
}
|
||
}
|