Files
Cyrene_v2/internal/channel/mediafetch/inline_test.go
T
AskaEth 6a545badbd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): IM 入站图片采集层(图片白名单 + 微信图片来源 + 复用 vision 内联入口)
- mediafetch 新增**图片白名单**(扩展名 + **字节嗅探兜底**;与文档/音频**三份名单互不污染**),
  取回/裁决/上限全部走既有 Fetch(**来源白名单 deny-by-default** + 逐跳重定向校验 + 边读边限 + 超时)
  ⇒ 没有新造第四份取回实现
- 微信:给图片 URL 配来源白名单(沿用该渠道 CDN 根口径)
- vision 的 `data:` URI 内联入口为**只读复用**(未改 vision 一行)
- **行为变更**:图片现在**会**被尝试取回(**字节嗅探是唯一裁决权** —— 不是图片字节就不写 `Data`);
  其它渠道尚无图片来源白名单 ⇒ 由 deny-by-default 拒绝(**安全**,只是暂读不到,走诚实降级)——
  故同步更新了 kook 的既有测试(原断言「图片保持既有路径 = 不取回」已随口径演进)
- ⚠️ **`channelbridge` 一个字没碰**(接线点在切片报告 §7),等监工接线 + 补其它渠道白名单
- 测试:3 个新文件;变异 5 条
2026-10-05 11:04:41 +08:00

248 lines
8.2 KiB
Go

package mediafetch
// 本文件锁定共享内联循环的口径(切片 IM-1):预筛、限额、条数上限、失败不炸。
// 「正文里的任意 URL ⇒ 0 次命中」这条红线在各适配器的用例里断言(只有适配器才知道
// 正文长什么样);这里锁的是「循环只会把**媒体引用**交给 Fetcher」这条结构性事实。
import (
"context"
"errors"
"io"
"log/slog"
"strings"
"sync"
"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"
)
// countingFetcher 记录每次被要求取回的媒体引用(引用原串与次数),使「0 次命中」
// 与「命中次数」都可断言。
type countingFetcher struct {
mu sync.Mutex
seen []string
data []byte
err error
maxHit int
}
func (f *countingFetcher) Fetch(_ context.Context, m adapter.Media) ([]byte, error) {
f.mu.Lock()
f.seen = append(f.seen, m.URL)
n := len(f.seen)
f.mu.Unlock()
if f.maxHit > 0 && n > f.maxHit {
return nil, errors.New("超过了本用例允许的取回次数")
}
if f.err != nil {
return nil, f.err
}
return f.data, nil
}
func (f *countingFetcher) calls() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.seen)
}
// testLogger 返回一个丢弃输出的带来源日志器:用例不关心内容,只保证路径不 panic。
func testLogger(t *testing.T) *slog.Logger {
t.Helper()
return logger.WithSource(logger.New(logger.Options{Out: io.Discard}), "internal/channel/mediafetch")
}
// txtMessage 构造一条「带一个 .txt 附件」的入站消息。
func txtMessage(url string) *adapter.UnifiedMessage {
return &adapter.UnifiedMessage{
Platform: "test",
ContentType: adapter.ContentFile,
Content: "看看这个文件 " + url, // 正文里也带同一个 URL:正文**不参与**取回决策
Media: []adapter.Media{{
Kind: adapter.MediaFile, URL: url, Name: "notes.txt", MIMEType: "text/plain", Size: 12,
}},
}
}
// TestInlineDownloadsAllowedFile 是正向用例:白名单格式的文件附件被内联进 Data。
func TestInlineDownloadsAllowedFile(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: []byte("hello\n")}
msg := txtMessage("https://cdn.example.com/notes.txt")
Inline(context.Background(), testLogger(t), msg, f)
if f.calls() != 1 {
t.Fatalf("取回次数 = %d, want 1", f.calls())
}
if string(msg.Media[0].Data) != "hello\n" {
t.Fatalf("Data = %q, want 取回的字节", msg.Media[0].Data)
}
}
// TestInlineNeverFetchesFromBody 钉住结构性事实:循环只把 `Media[].URL` 交给
// Fetcher —— 正文里那个 URL 永远不是取回对象(它是同一条文本,但只作为字符串存在)。
func TestInlineNeverFetchesFromBody(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: []byte("ok")}
msg := txtMessage("https://cdn.example.com/notes.txt")
bodyURL := "https://evil.example.com/secret.txt"
msg.Content = "读一下 " + bodyURL + " 和 " + msg.Media[0].URL
msg.Media = nil // 只有正文带 URL、没有任何文件媒体
Inline(context.Background(), testLogger(t), msg, f)
if f.calls() != 0 {
t.Fatalf("正文里的 URL 被取回了(%v),want 0 次", f.seen)
}
}
// TestInlineSkipsUnsupportedExtensionWithoutFetch 钉住预筛:非白名单格式**不下载**。
func TestInlineSkipsUnsupportedExtensionWithoutFetch(t *testing.T) {
t.Parallel()
for _, name := range []string{"payload.exe", "script.js", "page.html", "archive.zip", ""} {
t.Run(name, func(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: []byte("x")}
msg := txtMessage("https://cdn.example.com/whatever")
msg.Media[0].Name = name
Inline(context.Background(), testLogger(t), msg, f)
if f.calls() != 0 {
t.Fatalf("格式 %q 不在白名单里却发起了取回", name)
}
if len(msg.Media[0].Data) != 0 {
t.Fatalf("格式 %q 不该被内联", name)
}
})
}
}
// TestInlineSkipsDeclaredOversizeWithoutFetch 钉住「平台声明的体积超上限时连请求都
// 不发」(省一次必然失败的大传输)。
func TestInlineSkipsDeclaredOversizeWithoutFetch(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: []byte("x")}
msg := txtMessage("https://cdn.example.com/big.txt")
msg.Media[0].Size = documents.DefaultMaxBytes + 1
Inline(context.Background(), testLogger(t), msg, f)
if f.calls() != 0 {
t.Fatalf("声明超限的附件仍被取回 %d 次,want 0", f.calls())
}
}
// TestInlineFailureKeepsMessageAndContinues 钉住「失败不炸」:一份取不到不阻断其余
// 附件,也不改动消息的其他字段。
func TestInlineFailureKeepsMessageAndContinues(t *testing.T) {
t.Parallel()
f := &countingFetcher{err: ErrBadStatus}
msg := txtMessage("https://cdn.example.com/a.txt")
msg.Media = append(msg.Media, adapter.Media{
Kind: adapter.MediaFile, URL: "https://cdn.example.com/b.txt", Name: "b.txt",
})
Inline(context.Background(), testLogger(t), msg, f)
if f.calls() != 2 {
t.Fatalf("取回次数 = %d, want 2(一次失败不阻断第二次)", f.calls())
}
for i, m := range msg.Media {
if len(m.Data) != 0 {
t.Fatalf("Media[%d] 在失败时被写入了字节", i)
}
}
if !strings.Contains(msg.Content, "https://cdn.example.com/a.txt") {
t.Fatalf("失败路径改动了正文:%q", msg.Content)
}
}
// TestInlineCapsAttachmentsPerMessage 钉住条数上限与 Web 侧同源(documents.MaxPerMessage)。
func TestInlineCapsAttachmentsPerMessage(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: []byte("x")}
msg := txtMessage("https://cdn.example.com/0.txt")
for i := 1; i <= documents.MaxPerMessage+2; i++ {
msg.Media = append(msg.Media, adapter.Media{
Kind: adapter.MediaFile, URL: "https://cdn.example.com/x", Name: "x.txt",
})
}
Inline(context.Background(), testLogger(t), msg, f)
if f.calls() != documents.MaxPerMessage {
t.Fatalf("取回次数 = %d, want %d(与 Web 侧同一上限)", f.calls(), documents.MaxPerMessage)
}
}
// TestInlineSkipsMediaThatAlreadyHasBytes 钉住「已有字节不重复下载」(本地产出的
// 附件照原样通过)。
func TestInlineSkipsMediaThatAlreadyHasBytes(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: []byte("new")}
msg := txtMessage("https://cdn.example.com/a.txt")
msg.Media[0].Data = []byte("local")
Inline(context.Background(), testLogger(t), msg, f)
if f.calls() != 0 {
t.Fatalf("已有字节的附件被重复下载 %d 次", f.calls())
}
if string(msg.Media[0].Data) != "local" {
t.Fatalf("本地字节被覆盖:%q", msg.Media[0].Data)
}
}
// TestInlineIgnoresNonImageAndNonAudioVoice 钉住分支边界:**声明不是图片格式**的图片媒体
// (这里是 `.txt`)与格式不可转写的语音一样**不取字节**(切片 IM-IMG 起,声明命中图片白名单
// 的图片**会**被 `inlineImages` 取回,见 inline_image_test.go;音频白名单里的语音同理见
// inline_voice_test.go)。
func TestInlineIgnoresNonImageAndNonAudioVoice(t *testing.T) {
t.Parallel()
f := &countingFetcher{data: []byte("x")}
msg := &adapter.UnifiedMessage{
Platform: "test",
ContentType: adapter.ContentImage,
Media: []adapter.Media{
{Kind: adapter.MediaImage, URL: "https://cdn.example.com/a.txt", Name: "a.txt"},
{Kind: adapter.MediaVoice, URL: "https://cdn.example.com/b.txt", Name: "b.txt"},
},
}
Inline(context.Background(), testLogger(t), msg, f)
if f.calls() != 0 {
t.Fatalf("声明不是图片格式的媒体被取回了 %d 次,want 0", f.calls())
}
}
// TestDisplayNameFallsBackToURLPath 钉住缺名时的兜底:从资产地址路径末段取扩展名。
func TestDisplayNameFallsBackToURLPath(t *testing.T) {
t.Parallel()
cases := []struct {
name string
url string
want string
}{
{"文件名优先", "https://cdn.example.com/download?id=1", "report.csv"},
{"无名字取路径末段", "https://cdn.example.com/2024/report.csv", "report.csv"},
{"路径无末段则空", "https://cdn.example.com/", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
m := adapter.Media{Name: "", URL: tc.url}
if tc.name == "文件名优先" {
m.Name = "report.csv"
}
if got := displayName(m); got != tc.want {
t.Fatalf("displayName = %q, want %q", got, tc.want)
}
})
}
}