- mediafetch 新增**图片白名单**(扩展名 + **字节嗅探兜底**;与文档/音频**三份名单互不污染**),
取回/裁决/上限全部走既有 Fetch(**来源白名单 deny-by-default** + 逐跳重定向校验 + 边读边限 + 超时)
⇒ 没有新造第四份取回实现
- 微信:给图片 URL 配来源白名单(沿用该渠道 CDN 根口径)
- vision 的 `data:` URI 内联入口为**只读复用**(未改 vision 一行)
- **行为变更**:图片现在**会**被尝试取回(**字节嗅探是唯一裁决权** —— 不是图片字节就不写 `Data`);
其它渠道尚无图片来源白名单 ⇒ 由 deny-by-default 拒绝(**安全**,只是暂读不到,走诚实降级)——
故同步更新了 kook 的既有测试(原断言「图片保持既有路径 = 不取回」已随口径演进)
- ⚠️ **`channelbridge` 一个字没碰**(接线点在切片报告 §7),等监工接线 + 补其它渠道白名单
- 测试:3 个新文件;变异 5 条
486 lines
19 KiB
Go
486 lines
19 KiB
Go
package mediafetch
|
||
|
||
// 本文件锁定**入站图片字节内联**的口径(切片 IM-IMG):
|
||
// - 正向:白名单声明的图片、以及「平台类型即声明」的无名无 MIME 图片(微信 iLink 的形状)
|
||
// 都会被取回并写进 `Media.Data`(①);
|
||
// - 反证:非白名单声明/非图片字节 ⇒ **0 次取回**;顶着图片声明的非图片字节取回后
|
||
// **绝不内联**(②);
|
||
// - 超限:声明超限不取回、取回的字节超限不内联,两条都留一条诚实日志(③);
|
||
// - 失败/超时:不炸、不静默(日志带来源,**绝不记 URL 原串**)(④);
|
||
// - **图片白名单绝不污染文档白名单与音频白名单**(⑤)。
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"log/slog"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||
)
|
||
|
||
// 各格式的**最小字节样本**:`http.DetectContentType` 只认前缀签名,因此这些就是
|
||
// 「真图片」在该函数眼中的全部依据(TIFF 由本包自己的 tiffHeader 补签名)。
|
||
var (
|
||
pngSample = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body")
|
||
jpegSample = []byte("\xff\xd8\xff\xe0" + "fake-jpeg-body")
|
||
gifSample = []byte("GIF89a" + "fake-gif-body")
|
||
webpSample = []byte("RIFF\x00\x00\x00\x00WEBPVP8 " + "fake-webp-body")
|
||
bmpSample = []byte("BM\x00\x00\x00\x00" + "fake-bmp-body")
|
||
tiffSample = []byte("II\x2a\x00\x08\x00\x00\x00" + "fake-tiff-body")
|
||
tiffBE = []byte("MM\x00\x2a\x00\x00\x00\x08" + "fake-tiff-body")
|
||
htmlSample = []byte("<!DOCTYPE html><html><body>PROXY error page</body></html>")
|
||
textSample = []byte("这不是图片,只是一段文本。")
|
||
)
|
||
|
||
// imageMessage 构造一条「只有一张图片」的入站消息(Kind 明确,与 QQ/kook 的形状一致)。
|
||
func imageMessage(m adapter.Media) *adapter.UnifiedMessage {
|
||
return &adapter.UnifiedMessage{
|
||
Platform: "test",
|
||
ContentType: adapter.ContentImage,
|
||
Media: []adapter.Media{m},
|
||
}
|
||
}
|
||
|
||
// logBuffer 返回一个把日志写进内存的带来源日志器:用例据此断言「失败不静默」。
|
||
func logBuffer(t *testing.T) (*slog.Logger, *bytes.Buffer) {
|
||
t.Helper()
|
||
var buf bytes.Buffer
|
||
return logger.WithSource(logger.New(logger.Options{Level: "debug", Out: &buf}),
|
||
"internal/channel/mediafetch"), &buf
|
||
}
|
||
|
||
// TestInlineImageFetchesWhitelistedImage 是正向用例:扩展名与字节都是白名单图片 ⇒ 取回一次
|
||
// 并写进 Data(并且把**嗅探所得**的 MIME 记进日志,不是平台声明的那一个)。
|
||
func TestInlineImageFetchesWhitelistedImage(t *testing.T) {
|
||
t.Parallel()
|
||
f := &countingFetcher{data: pngSample}
|
||
msg := imageMessage(adapter.Media{
|
||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png", MIMEType: "image/png",
|
||
})
|
||
|
||
Inline(context.Background(), testLogger(t), msg, f)
|
||
|
||
if f.calls() != 1 {
|
||
t.Fatalf("取回次数 = %d, want 1", f.calls())
|
||
}
|
||
if !bytes.Equal(msg.Media[0].Data, pngSample) {
|
||
t.Fatalf("Data = %q, want 取回的图片字节", msg.Media[0].Data)
|
||
}
|
||
}
|
||
|
||
// TestInlineImageFetchesPlatformTypedImageWithoutNameOrMIME 是**微信 iLink 的形状**:
|
||
// image_item 既没有 file_name 也没有 mime,只有平台类型与 CDN 引用 —— 这正是
|
||
// 「只认扩展名就会把微信图片整条挡掉」的那个缺口。
|
||
func TestInlineImageFetchesPlatformTypedImageWithoutNameOrMIME(t *testing.T) {
|
||
t.Parallel()
|
||
for _, tc := range []struct {
|
||
name string
|
||
data []byte
|
||
mime string
|
||
}{
|
||
{"gif", gifSample, "image/gif"},
|
||
{"jpeg", jpegSample, "image/jpeg"},
|
||
{"webp", webpSample, "image/webp"},
|
||
{"bmp", bmpSample, "image/bmp"},
|
||
{"tiff 小端", tiffSample, "image/tiff"},
|
||
{"tiff 大端", tiffBE, "image/tiff"},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
f := &countingFetcher{data: tc.data}
|
||
msg := imageMessage(adapter.Media{
|
||
Kind: adapter.MediaImage,
|
||
// 无 Name、无 MIMEType:引用形如 CDN 的相对下载引用(归一后是绝对地址)。
|
||
URL: "https://cdn.example.com/c2c/download?encrypted_query_param=SECRET-VALUE",
|
||
})
|
||
|
||
Inline(context.Background(), testLogger(t), msg, f)
|
||
|
||
if f.calls() != 1 {
|
||
t.Fatalf("取回次数 = %d, want 1(平台类型即声明)", f.calls())
|
||
}
|
||
if !bytes.Equal(msg.Media[0].Data, tc.data) {
|
||
t.Fatalf("Data = %q, want 取回的字节", msg.Media[0].Data)
|
||
}
|
||
if got, ok := ImageMIMEOf(msg.Media[0].Data); !ok || got != tc.mime {
|
||
t.Fatalf("ImageMIMEOf = %q/%v, want %s/true", got, ok, tc.mime)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestInlineImageWithCaptionIsStillInlined 钉住与语音**刻意不同**的一条:消息带正文时图片
|
||
// 仍要取(配文的图同样是一份内容),绝不能照语音那条「已有文字就跳过」的口径走。
|
||
func TestInlineImageWithCaptionIsStillInlined(t *testing.T) {
|
||
t.Parallel()
|
||
f := &countingFetcher{data: pngSample}
|
||
msg := imageMessage(adapter.Media{
|
||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png",
|
||
})
|
||
msg.Content = "看看这张图 [图片]"
|
||
|
||
Inline(context.Background(), testLogger(t), msg, f)
|
||
|
||
if f.calls() != 1 || len(msg.Media[0].Data) == 0 {
|
||
t.Fatalf("带正文的图片没有被内联:calls=%d data=%d 字节", f.calls(), len(msg.Media[0].Data))
|
||
}
|
||
}
|
||
|
||
// TestInlineImageSkipsNonImageDeclarationWithoutFetch 是**反证**(②):声明不指向白名单
|
||
// 图片时**一个字节都不发出去**(取回次数 0,Data 为空)。
|
||
func TestInlineImageSkipsNonImageDeclarationWithoutFetch(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
url string
|
||
m adapter.Media
|
||
}{
|
||
{"扩展名是 html", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, Name: "page.html"}},
|
||
{"扩展名是 exe", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, Name: "payload.exe"}},
|
||
{"扩展名是 zip", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, Name: "bundle.zip"}},
|
||
{"地址末段是 zip", "https://cdn.example.com/bundle.zip", adapter.Media{Kind: adapter.MediaImage}},
|
||
{"声明 MIME 是 html", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, MIMEType: "text/html"}},
|
||
{"声明 MIME 是 svg", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, MIMEType: "image/svg+xml"}},
|
||
{"声明 MIME 是 pdf", "https://cdn.example.com/x", adapter.Media{Kind: adapter.MediaImage, MIMEType: "application/pdf"}},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
f := &countingFetcher{data: pngSample}
|
||
m := tc.m
|
||
m.URL = tc.url
|
||
msg := imageMessage(m)
|
||
|
||
Inline(context.Background(), testLogger(t), msg, f)
|
||
|
||
if f.calls() != 0 {
|
||
t.Fatalf("非图片声明发起了 %d 次取回,want 0(反证)", f.calls())
|
||
}
|
||
if len(msg.Media[0].Data) != 0 {
|
||
t.Fatal("非图片声明被内联了")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestInlineImageSniffVetoesMislabeledBytes 钉住「字节嗅探是唯一裁决权」(②的后半):
|
||
// 声明是 `.png`/`image/png`,字节却是 HTML ⇒ 取回发生过(必须看字节才知道),但**绝不内联**,
|
||
// 并且留一条诚实日志。去掉嗅探(只信声明)这条用例就会红。
|
||
func TestInlineImageSniffVetoesMislabeledBytes(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
data []byte
|
||
want string
|
||
}{
|
||
{"HTML 错误页", htmlSample, "not a whitelisted raster image"},
|
||
{"纯文本", textSample, "not a whitelisted raster image"},
|
||
{"空响应体", nil, "returned no bytes"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
f := &countingFetcher{data: tc.data}
|
||
msg := imageMessage(adapter.Media{
|
||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png", MIMEType: "image/png",
|
||
})
|
||
log, buf := logBuffer(t)
|
||
|
||
Inline(context.Background(), log, msg, f)
|
||
|
||
if len(msg.Media[0].Data) != 0 {
|
||
t.Fatalf("非图片字节(len=%d)被内联进了上下文", len(msg.Media[0].Data))
|
||
}
|
||
if !strings.Contains(buf.String(), tc.want) {
|
||
t.Fatalf("字节被否决时没有留下诚实日志(want %q):%q", tc.want, buf.String())
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestInlineImageDeclaredOversizeNotFetched 钉住声明超限不取回(③前半)。
|
||
func TestInlineImageDeclaredOversizeNotFetched(t *testing.T) {
|
||
t.Parallel()
|
||
f := &countingFetcher{data: pngSample}
|
||
msg := imageMessage(adapter.Media{
|
||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/big.png", Name: "big.png",
|
||
Size: MaxImageBytes + 1,
|
||
})
|
||
log, buf := logBuffer(t)
|
||
|
||
Inline(context.Background(), log, msg, f)
|
||
|
||
if f.calls() != 0 {
|
||
t.Fatalf("声明超限的图片仍被取回 %d 次,want 0", f.calls())
|
||
}
|
||
if !strings.Contains(buf.String(), "declared size over limit") {
|
||
t.Fatalf("声明超限没有留下日志:%q", buf.String())
|
||
}
|
||
}
|
||
|
||
// TestInlineImageFetchedOversizeNotInlined 钉住「取回的字节超限一律不内联」(③后半,纵深):
|
||
// 渠道 Fetcher 没按图片口径设上限时(各渠道尚未全部改用 MaxBytesFor),取回层这一道
|
||
// 仍然拦得住,绝不会把一份巨图塞进上下文。
|
||
func TestInlineImageFetchedOversizeNotInlined(t *testing.T) {
|
||
t.Parallel()
|
||
f := &countingFetcher{data: append(append([]byte(nil), pngSample...), make([]byte, MaxImageBytes)...)}
|
||
msg := imageMessage(adapter.Media{
|
||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/huge.png", Name: "huge.png",
|
||
})
|
||
log, buf := logBuffer(t)
|
||
|
||
Inline(context.Background(), log, msg, f)
|
||
|
||
if len(msg.Media[0].Data) != 0 {
|
||
t.Fatal("超限的图片字节被内联了")
|
||
}
|
||
if !strings.Contains(buf.String(), "fetched size over limit") {
|
||
t.Fatalf("超限没有留下日志:%q", buf.String())
|
||
}
|
||
}
|
||
|
||
// TestInlineImageSkipsExistingBytesAndCapsPerMessage 钉住两条省流与限额:已有字节不重复
|
||
// 下载;同一条消息里最多内联 `documents.MaxPerMessage` 张。
|
||
func TestInlineImageSkipsExistingBytesAndCapsPerMessage(t *testing.T) {
|
||
t.Parallel()
|
||
t.Run("已有字节", func(t *testing.T) {
|
||
t.Parallel()
|
||
f := &countingFetcher{data: pngSample}
|
||
msg := imageMessage(adapter.Media{
|
||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png", Data: []byte("local"),
|
||
})
|
||
|
||
Inline(context.Background(), testLogger(t), msg, f)
|
||
|
||
if f.calls() != 0 || string(msg.Media[0].Data) != "local" {
|
||
t.Fatalf("已有字节的图片被重复下载或覆盖:calls=%d data=%q", f.calls(), msg.Media[0].Data)
|
||
}
|
||
})
|
||
t.Run("条数上限", func(t *testing.T) {
|
||
t.Parallel()
|
||
f := &countingFetcher{data: pngSample}
|
||
msg := imageMessage(adapter.Media{
|
||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/0.png", Name: "0.png",
|
||
})
|
||
for i := 1; i <= documents.MaxPerMessage+2; i++ {
|
||
msg.Media = append(msg.Media, adapter.Media{
|
||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/x.png", Name: "x.png",
|
||
})
|
||
}
|
||
|
||
Inline(context.Background(), testLogger(t), msg, f)
|
||
|
||
if f.calls() != documents.MaxPerMessage {
|
||
t.Fatalf("取回次数 = %d, want %d", f.calls(), documents.MaxPerMessage)
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestInlineImageQuotaIsSeparateFromFilesAndVoices 钉住「三条链路各自计额度」:同一条消息里
|
||
// 的文件、图片、语音互不吃掉对方的条数额度。
|
||
func TestInlineImageQuotaIsSeparateFromFilesAndVoices(t *testing.T) {
|
||
t.Parallel()
|
||
f := &countingFetcher{data: pngSample}
|
||
msg := &adapter.UnifiedMessage{Platform: "test"}
|
||
for i := 0; i < documents.MaxPerMessage; i++ {
|
||
msg.Media = append(msg.Media,
|
||
adapter.Media{Kind: adapter.MediaFile, URL: "https://cdn.example.com/f.txt", Name: "f.txt"})
|
||
}
|
||
msg.Media = append(msg.Media,
|
||
adapter.Media{Kind: adapter.MediaImage, URL: "https://cdn.example.com/p.png", Name: "p.png"})
|
||
msg.Media = append(msg.Media,
|
||
adapter.Media{Kind: adapter.MediaVoice, URL: "https://cdn.example.com/v.ogg", Name: "v.ogg"})
|
||
|
||
Inline(context.Background(), testLogger(t), msg, f)
|
||
|
||
// 5 个文件 + 1 张图 + 1 条语音:三条链路各自在额度内,全部取回。
|
||
if f.calls() != documents.MaxPerMessage+2 {
|
||
t.Fatalf("取回次数 = %d, want %d(三条链路各自的额度不许互相吃掉)",
|
||
f.calls(), documents.MaxPerMessage+2)
|
||
}
|
||
}
|
||
|
||
// TestInlineImageFailureIsHonestNotSilent 钉住失败/超时路径(④):不炸、消息其余字段不动、
|
||
// 留一条**带来源**的 Warn;并且日志里**只有来源主机**,绝不出现完整 URL(可能带签名凭据)。
|
||
func TestInlineImageFailureIsHonestNotSilent(t *testing.T) {
|
||
t.Parallel()
|
||
const secret = "encrypted_query_param=SUPER_SECRET_VALUE"
|
||
cases := []struct {
|
||
name string
|
||
err error
|
||
}{
|
||
{"平台返回非成功状态", ErrBadStatus},
|
||
{"超时", context.DeadlineExceeded},
|
||
{"来源不在白名单", ErrOriginNotAllowed},
|
||
{"超过体积上限", ErrTooLarge},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
f := &countingFetcher{err: tc.err}
|
||
msg := imageMessage(adapter.Media{
|
||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/c2c/download?" + secret, Name: "a.png",
|
||
})
|
||
msg.Content = "[图片]"
|
||
log, buf := logBuffer(t)
|
||
|
||
Inline(context.Background(), log, msg, f)
|
||
|
||
if len(msg.Media[0].Data) != 0 {
|
||
t.Fatal("失败时仍写入了字节")
|
||
}
|
||
if msg.Content != "[图片]" {
|
||
t.Fatalf("失败路径改动了正文:%q(诚实提示由桥接器负责)", msg.Content)
|
||
}
|
||
out := buf.String()
|
||
if !strings.Contains(out, "inbound image inline failed") {
|
||
t.Fatalf("失败路径静默了:%q", out)
|
||
}
|
||
if !strings.Contains(out, "source=internal/channel/mediafetch") {
|
||
t.Fatalf("日志没有带来源(AGENTS §10):%q", out)
|
||
}
|
||
if !strings.Contains(out, "host=https://cdn.example.com") {
|
||
t.Fatalf("日志里没有来源主机,定位不到是哪台平台主机:%q", out)
|
||
}
|
||
if strings.Contains(out, secret) || strings.Contains(out, "/c2c/download") {
|
||
t.Fatalf("日志泄露了完整 URL 或签名参数:%q", out)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestInlineImageEmptyBytesIsHonest 钉住「取到 0 字节」这条分支同样不静默。
|
||
func TestInlineImageEmptyBytesIsHonest(t *testing.T) {
|
||
t.Parallel()
|
||
f := &countingFetcher{data: nil}
|
||
msg := imageMessage(adapter.Media{
|
||
Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.png", Name: "a.png",
|
||
})
|
||
log, buf := logBuffer(t)
|
||
|
||
Inline(context.Background(), log, msg, f)
|
||
|
||
if !strings.Contains(buf.String(), "returned no bytes") {
|
||
t.Fatalf("0 字节路径静默了:%q", buf.String())
|
||
}
|
||
}
|
||
|
||
// TestImageWhitelistDoesNotPolluteOtherWhitelists 是本切片的硬红线(⑤):图片放行集合是
|
||
// **独立**的一份 —— 既不许污染 `documents.ExtAllowed`(Web 文档上传口径),也不许与音频
|
||
// 白名单互相渗透。
|
||
func TestImageWhitelistDoesNotPolluteOtherWhitelists(t *testing.T) {
|
||
t.Parallel()
|
||
imageExts := []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff"}
|
||
for _, ext := range imageExts {
|
||
if !imageExtAllowed(ext) {
|
||
t.Fatalf("imageExtAllowed(%q) = false, want true", ext)
|
||
}
|
||
if documents.ExtAllowed(ext) {
|
||
t.Fatalf("documents.ExtAllowed(%q) = true —— 图片格式污染了文档白名单", ext)
|
||
}
|
||
if audioExtAllowed(ext) {
|
||
t.Fatalf("audioExtAllowed(%q) = true —— 图片格式污染了音频白名单", ext)
|
||
}
|
||
}
|
||
// 反方向:文档/音频/可执行格式都不是图片。
|
||
for _, ext := range []string{".txt", ".pdf", ".mp3", ".ogg", ".wav", ".exe", ".svg", ".html", ""} {
|
||
if imageExtAllowed(ext) {
|
||
t.Fatalf("imageExtAllowed(%q) = true, want false", ext)
|
||
}
|
||
}
|
||
// 三份集合的规模与成员都不互相包含(防止「一条路复用两份名单」)。
|
||
if documents.ExtAllowed(".png") || documents.ExtAllowed(".mp3") || documents.ExtAllowed(".amr") {
|
||
t.Fatal("文档白名单里出现了图片或音频格式")
|
||
}
|
||
if audioExtAllowed(".png") || audioExtAllowed(".pdf") {
|
||
t.Fatal("音频白名单里出现了图片或文档格式")
|
||
}
|
||
}
|
||
|
||
// TestImageMIMEOfIsTheByteArbiter 钉住字节裁决本身:白名单光栅图认得出来、SVG/HTML/文本/
|
||
// 空字节一律判否,且 `image/jpg` 这种非标准写法被归一到 `image/jpeg`。
|
||
func TestImageMIMEOfIsTheByteArbiter(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
data []byte
|
||
want string
|
||
ok bool
|
||
}{
|
||
{"png", pngSample, "image/png", true},
|
||
{"jpeg", jpegSample, "image/jpeg", true},
|
||
{"gif", gifSample, "image/gif", true},
|
||
{"webp", webpSample, "image/webp", true},
|
||
{"bmp", bmpSample, "image/bmp", true},
|
||
{"tiff 小端", tiffSample, "image/tiff", true},
|
||
{"tiff 大端", tiffBE, "image/tiff", true},
|
||
{"html 错误页", htmlSample, "", false},
|
||
{"纯文本", textSample, "", false},
|
||
{"svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`), "", false},
|
||
{"空字节", nil, "", false},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
got, ok := ImageMIMEOf(tc.data)
|
||
if ok != tc.ok || got != tc.want {
|
||
t.Fatalf("ImageMIMEOf = %q/%v, want %q/%v", got, ok, tc.want, tc.ok)
|
||
}
|
||
})
|
||
}
|
||
if got, ok := knownImageMIME("image/jpg"); !ok || got != "image/jpeg" {
|
||
t.Fatalf("knownImageMIME(image/jpg) = %q/%v, want image/jpeg/true", got, ok)
|
||
}
|
||
if _, ok := knownImageMIME("image/svg+xml"); ok {
|
||
t.Fatal("SVG 被图片白名单放行了(它是可执行内容)")
|
||
}
|
||
}
|
||
|
||
// TestMaxBytesForPerKind 钉住「上限按类型分档」的唯一出处:图片与语音 5 MiB、其余文档类
|
||
// 20 MiB —— 各渠道 Fetcher 只该引用它,不许自己写数。
|
||
func TestMaxBytesForPerKind(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
m adapter.Media
|
||
want int64
|
||
}{
|
||
{"图片", adapter.Media{Kind: adapter.MediaImage}, MaxImageBytes},
|
||
{"语音", adapter.Media{Kind: adapter.MediaVoice}, MaxVoiceBytes},
|
||
{"文件", adapter.Media{Kind: adapter.MediaFile}, documents.DefaultMaxBytes},
|
||
{"无类型", adapter.Media{}, documents.DefaultMaxBytes},
|
||
}
|
||
for _, tc := range cases {
|
||
if got := MaxBytesFor(tc.m); got != tc.want {
|
||
t.Fatalf("%s: MaxBytesFor = %d, want %d", tc.name, got, tc.want)
|
||
}
|
||
}
|
||
if MaxImageBytes == documents.DefaultMaxBytes {
|
||
t.Fatal("图片上限与文档上限撞在同一个数上:本切片要求图片用 5 MiB、文档用 20 MiB")
|
||
}
|
||
}
|
||
|
||
// TestInlineImageIgnoresOtherMediaKinds 钉住分支边界:图片分支只碰图片媒体,文件/语音媒体
|
||
// 不会被它写入 Data(各自的分支负责),且非图片媒体的失败不会影响图片。
|
||
func TestInlineImageIgnoresOtherMediaKinds(t *testing.T) {
|
||
t.Parallel()
|
||
f := &countingFetcher{data: pngSample}
|
||
msg := &adapter.UnifiedMessage{
|
||
Platform: "test",
|
||
Media: []adapter.Media{
|
||
{Kind: adapter.MediaFile, URL: "https://cdn.example.com/f.txt", Name: "f.txt"},
|
||
{Kind: adapter.MediaVoice, URL: "https://cdn.example.com/v.silk", Name: "v.silk"},
|
||
},
|
||
}
|
||
log, _ := logBuffer(t)
|
||
|
||
inlineImages(context.Background(), log, msg, f)
|
||
|
||
if f.calls() != 0 {
|
||
t.Fatalf("图片分支对非图片媒体发起了 %d 次取回,want 0", f.calls())
|
||
}
|
||
}
|