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 的**图片来源白名单**就是既有那份媒体来源名单(`img.kookapp.cn` + `base_url`), // 不另立第三份;名单命中与「非名单来源 0 次请求」由 media_image_test.go 单独钉住。 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) } }) }