Files
Cyrene_v2/plugins/channels/kook/media_test.go
T
AskaEth 3be70f8232
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 适配器入站内联附件字节(telegram/kook/qq_obv11/weixin_ilink)
- 新增 internal/channel/mediafetch:来源白名单(deny-by-default,支持 *.suffix)
  + 逐跳重定向校验 + 边读边限 + 超时;只回来源主机,绝不回完整 URL
- 四渠道各自 media.go 接通入站内联:只从平台载荷给出的引用取回字节
  (telegram file_id→getFile→file_path 形态校验;kook CDN;qq 文件段 url;
   weixin cdnURL + AES-ECB 解密)
- 常量全部复用既有来源:documents.ExtAllowed / DefaultMaxBytes / MaxPerMessage
- channelbridge 诚实提示文案改为对「渠道只给远程引用」与「代取失败」两种成因都成立
- 18 个测试函数(含四渠道「正文里的 URL ⇒ 0 次命中」红线反证、端到端进模型可见视图)
- 变异验证:去掉尺寸上限 ⇒ 四渠道红(含修正后不再是假绿的 weixin 超量用例)
2026-10-04 22:13:17 +08:00

250 lines
7.9 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("文本消息没有被投递")
}
}
// TestInboundImageMediaKeepsExistingPath 钉住边界:图片类媒体不走文档内联。
func TestInboundImageMediaKeepsExistingPath(t *testing.T) {
t.Parallel()
s := newKookAssetServer(t)
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() != 0 {
t.Fatalf("图片媒体被文档内联取回了 %d 次,want 0", s.hits.Load())
}
if len(c.message(t).Media[0].Data) != 0 {
t.Fatal("图片媒体被写入了字节(应留给视觉链路)")
}
}