package qq_obv11 // 本文件锁定 OneBot v11 的**入站附件字节内联**(切片 IM-1):文件段里实现方给出的 // `url` 才是可取回的资产地址,白名单限死腾讯系主机 + 部署自己声明的 OneBot 端点。 // // 覆盖:正向、超限(边读边限)、非白名单格式不下载、来源不上白名单不下载、失败诚实 // 放弃、以及红线(正文/文本段里的 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" ) // qqAssetServer 是假的「OneBot 实现 + 文件服务端」:`/download/...` 计命中次数。 type qqAssetServer struct { srv *httptest.Server hits atomic.Int32 body []byte code int } func newQQAssetServer(t *testing.T) *qqAssetServer { t.Helper() s := &qqAssetServer{body: []byte("文件正文\n")} s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/download/") { 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 } // qqCapture 捕获投递给上层的最后一条消息。 type qqCapture struct { mu sync.Mutex last adapter.UnifiedMessage count int } func (c *qqCapture) fn(_ context.Context, msg adapter.UnifiedMessage) { c.mu.Lock() c.last = msg c.count++ c.mu.Unlock() } func (c *qqCapture) message(t *testing.T) adapter.UnifiedMessage { t.Helper() c.mu.Lock() defer c.mu.Unlock() if c.count == 0 { t.Fatal("入站消息没有被投递给上层") } return c.last } // newInlineQQAdapter 装配一个把 `api_base_url` 指到假服务端的适配器(该端点因此同时 // 进白名单,模拟本地 OneBot 实现自己下发文件 URL 的部署形态)+ 捕获器 + 日志缓冲。 func newInlineQQAdapter(t *testing.T, s *qqAssetServer) (*Adapter, *qqCapture, *bytes.Buffer) { t.Helper() logs := &bytes.Buffer{} a := NewAdapter(Config{ Mode: "server", APIBaseURL: s.srv.URL, Logger: logger.WithSource(slog.New(slog.NewTextHandler(logs, nil)), "plugins/channels/qq_obv11"), }) c := &qqCapture{} a.SetMessageHandler(c.fn) return a, c, logs } // qqFileEvent 构造一条私聊文件消息(数组形态的消息段)。 func qqFileEvent(fileName, fileURL string, extraSegments ...any) []byte { segs := []any{ map[string]any{"type": "text", "data": map[string]any{"text": "看看这个文件"}}, map[string]any{"type": "file", "data": map[string]any{"file": fileName, "url": fileURL}}, } segs = append(segs, extraSegments...) ev := map[string]any{ "post_type": "message", "message_type": "private", "time": 1700000000, "self_id": 100, "message_id": 7, "user_id": 200, "message": segs, "sender": map[string]any{"user_id": 200, "nickname": "Alice", "role": ""}, } raw, err := json.Marshal(ev) if err != nil { panic(fmt.Sprintf("marshal ob event: %v", err)) } return raw } // TestInboundFileIsInlined 正向:文件段的 `url` 被取回并内联。 func TestInboundFileIsInlined(t *testing.T) { t.Parallel() s := newQQAssetServer(t) a, c, _ := newInlineQQAdapter(t, s) a.handlePayload(context.Background(), qqFileEvent("report.txt", s.srv.URL+"/download/report.txt")) 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()) } } // TestInboundCQCodeFileIsInlined 正向(CQ 码形态):`[CQ:file,file=…,url=…]` 同样被内联。 func TestInboundCQCodeFileIsInlined(t *testing.T) { t.Parallel() s := newQQAssetServer(t) a, c, _ := newInlineQQAdapter(t, s) ev := map[string]any{ "post_type": "message", "message_type": "private", "time": 1700000000, "self_id": 100, "message_id": 8, "user_id": 200, "message": "[CQ:file,file=report.txt,url=" + s.srv.URL + "/download/report.txt]", "sender": map[string]any{"user_id": 200, "nickname": "Alice", "role": ""}, } raw, _ := json.Marshal(ev) a.handlePayload(context.Background(), raw) if string(c.message(t).Media[0].Data) != "文件正文\n" { t.Fatalf("CQ 码形态的文件未被内联") } } // TestInboundFileOffAllowlistIsNotDownloaded 钉住来源白名单:不在白名单里的主机 // **一个字节都不发出去**。 func TestInboundFileOffAllowlistIsNotDownloaded(t *testing.T) { t.Parallel() inside := newQQAssetServer(t) outside := newQQAssetServer(t) a, c, logs := newInlineQQAdapter(t, inside) a.handlePayload(context.Background(), qqFileEvent("report.txt", outside.srv.URL+"/download/report.txt")) 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") { t.Fatalf("缺少带来源的失败日志:%q", logs.String()) } } // TestInboundFileWithoutURLIsNotDownloaded 钉住「没有真 url 就不下载」:段里只有文件 // id/文件名时(`url` 缺失,`file` 被当作引用),绝不拼凑地址去猜,也绝不发请求。 func TestInboundFileWithoutURLIsNotDownloaded(t *testing.T) { t.Parallel() s := newQQAssetServer(t) a, c, _ := newInlineQQAdapter(t, s) a.handlePayload(context.Background(), qqFileEvent("report.txt", "")) if s.hits.Load() != 0 { t.Fatalf("无 url 的文件段发起了 %d 次请求,want 0", s.hits.Load()) } if len(c.message(t).Media[0].Data) != 0 { t.Fatal("无 url 的文件段被内联了") } } // TestInboundUnsupportedExtensionIsNotDownloaded 钉住预筛:非白名单格式不下载。 func TestInboundUnsupportedExtensionIsNotDownloaded(t *testing.T) { t.Parallel() s := newQQAssetServer(t) a, c, _ := newInlineQQAdapter(t, s) a.handlePayload(context.Background(), qqFileEvent("payload.exe", s.srv.URL+"/download/payload.exe")) 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 钉住边读边限:OneBot 段不给体积,只能靠 // 上限本身兜住超量响应。 func TestInboundOversizeBodyIsCutWhileReading(t *testing.T) { t.Parallel() s := newQQAssetServer(t) s.body = bytes.Repeat([]byte("x"), documents.DefaultMaxBytes+64) a, c, logs := newInlineQQAdapter(t, s) a.handlePayload(context.Background(), qqFileEvent("big.txt", s.srv.URL+"/download/big.txt")) 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 := newQQAssetServer(t) s.code = http.StatusInternalServerError a, c, logs := newInlineQQAdapter(t, s) a.handlePayload(context.Background(), qqFileEvent("report.txt", s.srv.URL+"/download/report.txt")) msg := c.message(t) if len(msg.Media[0].Data) != 0 { t.Fatal("失败时仍写入了字节") } if msg.MessageID != "7" { t.Fatalf("失败路径把消息弄丢了:%+v", msg.MessageID) } if !strings.Contains(logs.String(), "inline failed") { t.Fatalf("失败没有留下带来源的日志:%q", logs.String()) } } // TestInboundBodyURLIsNeverFetched 是红线用例:文本段(消息正文)里带任意 URL 时 // **绝不**对它发起请求(断言 0 次命中)。 func TestInboundBodyURLIsNeverFetched(t *testing.T) { t.Parallel() s := newQQAssetServer(t) a, _, _ := newInlineQQAdapter(t, s) bodyURL := s.srv.URL + "/download/secret.txt" ev := map[string]any{ "post_type": "message", "message_type": "private", "time": 1700000000, "self_id": 100, "message_id": 9, "user_id": 200, "message": []any{ map[string]any{"type": "text", "data": map[string]any{"text": "读一下 " + bodyURL}}, }, "sender": map[string]any{"user_id": 200, "nickname": "Alice", "role": ""}, } raw, _ := json.Marshal(ev) a.handlePayload(context.Background(), raw) if s.hits.Load() != 0 { t.Fatalf("正文里的 URL 被请求了 %d 次,want 0", s.hits.Load()) } }