- 新增 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 超量用例)
250 lines
7.9 KiB
Go
250 lines
7.9 KiB
Go
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("图片媒体被写入了字节(应留给视觉链路)")
|
||
}
|
||
}
|