Files
Cyrene_v2/plugins/channels/kook/media_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

273 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package kook
// 本文件锁定 KOOK 的**入站附件字节内联**(切片 IM-1):KOOK 把附件地址放在消息的
// `content` 字段里,因此这里用假 HTTP 服务端同时充当「资产 CDN」与「已配置的 API 根」,
// 逐条断言正向、超限、预筛、失败与红线(正文里的 URL 绝不被取回)。
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"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"
)
// kookAssetServer 是假的「平台 + 资产」服务端:`/asset/...` 计文件下载命中次数,
// 其余路径一律 404。
type kookAssetServer struct {
srv *httptest.Server
hits atomic.Int32
body []byte
code int
}
func newKookAssetServer(t *testing.T) *kookAssetServer {
t.Helper()
s := &kookAssetServer{body: []byte("文件的正文\n")}
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/asset/") {
w.WriteHeader(http.StatusNotFound)
return
}
s.hits.Add(1)
if s.code != 0 {
w.WriteHeader(s.code)
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write(s.body)
}))
t.Cleanup(s.srv.Close)
return s
}
// kookCapture 捕获投递给上层的最后一条消息。
type kookCapture struct {
mu sync.Mutex
last adapter.UnifiedMessage
got int
}
func (c *kookCapture) fn(_ context.Context, msg adapter.UnifiedMessage) {
c.mu.Lock()
c.last = msg
c.got++
c.mu.Unlock()
}
func (c *kookCapture) message(t *testing.T) adapter.UnifiedMessage {
t.Helper()
c.mu.Lock()
defer c.mu.Unlock()
if c.got == 0 {
t.Fatal("入站消息没有被投递给上层")
}
return c.last
}
// newInlineKookAdapter 装配一个接到假服务端的适配器(base_url 指到它,因此它同时
// 是「平台 API 根」与「资产主机」)+ 捕获器 + 日志缓冲。
func newInlineKookAdapter(t *testing.T, s *kookAssetServer) (*Adapter, *kookCapture, *bytes.Buffer) {
t.Helper()
logs := &bytes.Buffer{}
a := New(
Config{BotToken: "testtoken"},
WithBaseURL(s.srv.URL),
WithHTTPClient(s.srv.Client()),
WithLogger(logger.WithSource(slog.New(slog.NewTextHandler(logs, nil)), "plugins/channels/kook")),
)
c := &kookCapture{}
a.SetMessageHandler(c.fn)
return a, c, logs
}
// kookEventJSON 构造一条 KOOK s=0 事件(type 与 content 由调用方给)。
func kookEventJSON(kind int, content string) json.RawMessage {
ev := map[string]any{
"channel_type": "GROUP", "type": kind, "target_id": "chan-1", "author_id": "u-1",
"content": content, "msg_id": "m-1", "msg_timestamp": 1700000000000, "nonce": "",
"extra": map[string]any{"type": kind, "guild_id": "g-1", "channel_name": "c",
"author": map[string]any{"id": "u-1", "username": "u", "nickname": "N"}},
}
raw, err := json.Marshal(ev)
if err != nil {
panic(fmt.Sprintf("marshal kook event: %v", err))
}
return raw
}
// TestInboundFileIsInlined 正向:文件消息(type 4)的 CDN 地址被取回并内联。
func TestInboundFileIsInlined(t *testing.T) {
t.Parallel()
s := newKookAssetServer(t)
a, c, _ := newInlineKookAdapter(t, s)
if err := a.handleEvent(context.Background(), kookEventJSON(kindFile, s.srv.URL+"/asset/report.txt"), nil); err != nil {
t.Fatalf("handleEvent: %v", err)
}
msg := c.message(t)
if len(msg.Media) != 1 || string(msg.Media[0].Data) != "文件的正文\n" {
t.Fatalf("Media.Data = %q, want 下载到的字节", msg.Media[0].Data)
}
if s.hits.Load() != 1 {
t.Fatalf("资产命中 = %d, want 1", s.hits.Load())
}
}
// TestInboundFileOffAllowlistIsNotDownloaded 钉住来源白名单:不在白名单里的主机
// **一个字节都不发出去**(断言 0 次命中)。
func TestInboundFileOffAllowlistIsNotDownloaded(t *testing.T) {
t.Parallel()
s := newKookAssetServer(t)
outside := newKookAssetServer(t)
a, c, logs := newInlineKookAdapter(t, s)
if err := a.handleEvent(context.Background(), kookEventJSON(kindFile, outside.srv.URL+"/asset/report.txt"), nil); err != nil {
t.Fatalf("handleEvent: %v", err)
}
if outside.hits.Load() != 0 {
t.Fatalf("白名单外的主机被请求了 %d 次,want 0", outside.hits.Load())
}
if len(c.message(t).Media[0].Data) != 0 {
t.Fatal("白名单外的附件被内联了")
}
if !strings.Contains(logs.String(), "inline failed") || !strings.Contains(logs.String(), "白名单") {
t.Fatalf("缺少带来源的失败日志:%q", logs.String())
}
}
// TestInboundUnsupportedExtensionIsNotDownloaded 钉住预筛:非白名单格式不下载。
func TestInboundUnsupportedExtensionIsNotDownloaded(t *testing.T) {
t.Parallel()
s := newKookAssetServer(t)
a, c, _ := newInlineKookAdapter(t, s)
if err := a.handleEvent(context.Background(), kookEventJSON(kindFile, s.srv.URL+"/asset/payload.exe"), nil); err != nil {
t.Fatalf("handleEvent: %v", err)
}
if s.hits.Load() != 0 {
t.Fatalf("非白名单格式发起了 %d 次请求,want 0", s.hits.Load())
}
if len(c.message(t).Media[0].Data) != 0 {
t.Fatal("非白名单格式被内联了")
}
}
// TestInboundOversizeBodyIsCutWhileReading 钉住边读边限(KOOK 事件不给体积,因此
// 只能靠上限本身兜住)。
func TestInboundOversizeBodyIsCutWhileReading(t *testing.T) {
t.Parallel()
s := newKookAssetServer(t)
s.body = bytes.Repeat([]byte("x"), documents.DefaultMaxBytes+64)
a, c, logs := newInlineKookAdapter(t, s)
if err := a.handleEvent(context.Background(), kookEventJSON(kindFile, s.srv.URL+"/asset/big.txt"), nil); err != nil {
t.Fatalf("handleEvent: %v", err)
}
if len(c.message(t).Media[0].Data) != 0 {
t.Fatal("超量响应被内联了")
}
if !strings.Contains(logs.String(), "inline failed") {
t.Fatalf("超量没有留下带来源的失败日志:%q", logs.String())
}
}
// TestInboundFetchFailureIsHonest 钉住失败路径:5xx ⇒ 放弃内联,消息照收。
func TestInboundFetchFailureIsHonest(t *testing.T) {
t.Parallel()
s := newKookAssetServer(t)
s.code = http.StatusBadGateway
a, c, logs := newInlineKookAdapter(t, s)
if err := a.handleEvent(context.Background(), kookEventJSON(kindFile, s.srv.URL+"/asset/report.txt"), nil); err != nil {
t.Fatalf("handleEvent: %v", err)
}
msg := c.message(t)
if len(msg.Media[0].Data) != 0 {
t.Fatal("失败时仍写入了字节")
}
if msg.ChannelID == "" {
t.Fatal("失败路径把消息弄丢了")
}
if !strings.Contains(logs.String(), "inline failed") {
t.Fatalf("失败没有留下带来源的日志:%q", logs.String())
}
}
// TestInboundBodyURLIsNeverFetched 是红线用例:正文(text / kmarkdown 的 content)
// 里带任意 URL 时**绝不**对它发起请求(断言 0 次命中)。
func TestInboundBodyURLIsNeverFetched(t *testing.T) {
t.Parallel()
s := newKookAssetServer(t)
a, c, _ := newInlineKookAdapter(t, s)
for _, kind := range []int{kindText, kindKMarkdown} {
if err := a.handleEvent(context.Background(), kookEventJSON(kind, "读一下 "+s.srv.URL+"/asset/secret.txt"), nil); err != nil {
t.Fatalf("handleEvent: %v", err)
}
}
if s.hits.Load() != 0 {
t.Fatalf("正文里的 URL 被请求了 %d 次,want 0", s.hits.Load())
}
if c.got == 0 {
t.Fatal("文本消息没有被投递")
}
}
// kookPNG 是一份「字节嗅探认得出来」的 PNG 载荷(`http.DetectContentType` 只认 8 字节签名)。
var kookPNG = []byte("\x89PNG\r\n\x1a\n" + "fake-png-body")
// TestInboundImageMediaIsInlinedOnlyWhenBytesAreImages 钉住切片 IM-IMG 之后的新边界:
// 图片类媒体**现在也进内联链路**(取回字节,供桥接器内联成 data: URI 进视觉链路),但
// 「字节嗅探是唯一裁决权」——假服务端回文本时它**绝不**被写进 `Data`,回真图字节时才写。
// ⚠️ KOOK 自己的图片来源白名单与上游接线属后续切片(本切片只做微信)。
func TestInboundImageMediaIsInlinedOnlyWhenBytesAreImages(t *testing.T) {
t.Parallel()
t.Run("字节不是图片: 取回但不内联", func(t *testing.T) {
t.Parallel()
s := newKookAssetServer(t) // 默认正文是文本(Content-Type: text/plain)
a, c, _ := newInlineKookAdapter(t, s)
if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, s.srv.URL+"/asset/photo.png"), nil); err != nil {
t.Fatalf("handleEvent: %v", err)
}
if s.hits.Load() != 1 {
t.Fatalf("图片声明的媒体取回次数 = %d, want 1(图片现在也进内联链路)", s.hits.Load())
}
if len(c.message(t).Media[0].Data) != 0 {
t.Fatal("非图片字节被写入了字节(嗅探是唯一裁决权)")
}
})
t.Run("字节是真图: 内联", func(t *testing.T) {
t.Parallel()
s := newKookAssetServer(t)
s.body = kookPNG
a, c, _ := newInlineKookAdapter(t, s)
if err := a.handleEvent(context.Background(), kookEventJSON(kindImage, s.srv.URL+"/asset/photo.png"), nil); err != nil {
t.Fatalf("handleEvent: %v", err)
}
if !bytes.Equal(c.message(t).Media[0].Data, kookPNG) {
t.Fatalf("Media.Data = %q, want 取回的图片字节", c.message(t).Media[0].Data)
}
})
}