package qq_obv11 // 本文件覆盖裁决 1.4 的**四个端**(http/ws × client/server)里的 HTTP 两端,以及 // transport/mode 配置项的默认值与归一化。WS 两端已有既有用例(TestAdapterServerModeRoundTrip / // TestAdapterClientModeReceiveAndSend),这里用本地 httptest 假 OneBot 实现驱动,绝不连真实 QQ。 import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // groupEventJSON 是一条标准的 OneBot v11 群消息上报。 const groupEventJSON = `{"post_type":"message","message_type":"group","time":1700000000,` + `"self_id":100,"message_id":9,"user_id":42,"group_id":123,"group_name":"Team",` + `"message":[{"type":"text","data":{"text":"hello"}}],` + `"sender":{"user_id":42,"nickname":"Bob","card":"Bobby","role":"member"}}` // recordedAPIRequest 是假 OneBot HTTP API 收到的一条 action 请求。 type recordedAPIRequest struct { action string auth string body []byte } // fakeOneBotAPI 是本地假的 OneBot HTTP API:记录收到的 action 请求,并按 retCode / // status 回一个标准响应信封,用来验证 HTTP client 端的「请求-响应 + 判成败」语义。 type fakeOneBotAPI struct { mu sync.Mutex requests []recordedAPIRequest retCode int status string } func (f *fakeOneBotAPI) handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) f.mu.Lock() f.requests = append(f.requests, recordedAPIRequest{ action: strings.TrimPrefix(r.URL.Path, "/"), auth: r.Header.Get("Authorization"), body: body, }) retCode, status := f.retCode, f.status f.mu.Unlock() if status == "" { status = "ok" } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "status": status, "retcode": retCode, "data": map[string]any{}, "msg": "fake", }) }) } func (f *fakeOneBotAPI) snapshot() []recordedAPIRequest { f.mu.Lock() defer f.mu.Unlock() return append([]recordedAPIRequest(nil), f.requests...) } // reportResult 是一次上报 POST 的结果。 type reportResult struct { status int body string } // postReport 模拟 OneBot 实现向 Cyrene 的上报端 POST 一条事件。 func postReport(t *testing.T, url, token, body string) reportResult { t.Helper() req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) if err != nil { t.Fatalf("build report request: %v", err) } req.Header.Set("Content-Type", "application/json") if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("post report to %s: %v", url, err) } defer func() { _ = resp.Body.Close() }() raw, _ := io.ReadAll(resp.Body) return reportResult{status: resp.StatusCode, body: string(raw)} } // TestHTTPClientModeSendsViaAPI 覆盖 **http + client**:Cyrene→OneBot(调 OneBot 的 // HTTP API)。断言 send_msg 真的发出去、出站 token 只在 Authorization 头里、该模式 // 不开任何监听端口,并且探活走的是 get_login_info。 func TestHTTPClientModeSendsViaAPI(t *testing.T) { t.Parallel() api := &fakeOneBotAPI{} srv := httptest.NewServer(api.handler()) defer srv.Close() a := NewAdapter(Config{ Transport: transportHTTP, Mode: modeClient, APIBaseURL: srv.URL, AccessToken: "secret-token", }) if err := a.Connect(context.Background()); err != nil { t.Fatalf("Connect error: %v", err) } defer func() { _ = a.Disconnect(context.Background()) }() if !a.IsConnected() { t.Error("IsConnected() = false after Connect, want true") } if addr := a.Addr(); addr != "" { t.Errorf("http client mode must not listen, Addr() = %q", addr) } if err := a.HealthCheck(context.Background()); err != nil { t.Errorf("HealthCheck() error = %v, want nil", err) } if err := a.SendMessage(context.Background(), adapter.UnifiedResponse{ Messages: []adapter.UnifiedMessage{{ChannelType: adapter.ChannelGroup, GroupID: "123", Content: "pong"}}, }); err != nil { t.Fatalf("SendMessage error: %v", err) } reqs := api.snapshot() if len(reqs) != 2 { t.Fatalf("api requests = %+v, want health probe + send_msg", reqs) } if reqs[0].action != "get_login_info" || reqs[1].action != "send_msg" { t.Errorf("actions = %q,%q want get_login_info,send_msg", reqs[0].action, reqs[1].action) } for _, r := range reqs { if r.auth != "Bearer secret-token" { t.Errorf("%s Authorization = %q, want Bearer header", r.action, r.auth) } if strings.Contains(string(r.body), "access_token") { t.Errorf("%s body carries access_token: %s", r.action, r.body) } } assertSendMsg(t, reqs[1].body) } // TestHTTPServerModeReceivesReport 覆盖 **http + server**:OneBot→Cyrene(OneBot 把 // 事件上报 POST 到 Cyrene 的监听端口)。断言事件被归一化派发、成功回 204;只收不发 // 的端没有出站通道,SendMessage 必须明确报未连接而不是静默成功。 func TestHTTPServerModeReceivesReport(t *testing.T) { t.Parallel() a := NewAdapter(Config{ Transport: transportHTTP, Mode: modeServer, ListenAddr: "127.0.0.1:0", AccessToken: "secret-token", ReportPath: "/onebot/report", }) got := make(chan adapter.UnifiedMessage, 1) a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) { got <- m }) if err := a.Connect(context.Background()); err != nil { t.Fatalf("Connect error: %v", err) } defer func() { _ = a.Disconnect(context.Background()) }() addr := a.Addr() if addr == "" { t.Fatal("Addr() empty after Connect, want the report listener") } if res := postReport(t, "http://"+addr+"/onebot/report", "secret-token", groupEventJSON); res.status != http.StatusNoContent { t.Fatalf("report status = %d (body %q), want 204", res.status, res.body) } select { case m := <-got: if m.ChannelType != adapter.ChannelGroup || m.GroupID != "123" || m.Content != "hello" { t.Errorf("report message = %+v, want group/123/hello", m) } case <-time.After(3 * time.Second): t.Fatal("http report never reached the message handler") } err := a.SendMessage(context.Background(), adapter.UnifiedResponse{ Messages: []adapter.UnifiedMessage{{ChannelType: adapter.ChannelGroup, GroupID: "123", Content: "pong"}}, }) if !errors.Is(err, adapter.ErrNotConnected) { t.Errorf("SendMessage error = %v, want ErrNotConnected (no api_base_url)", err) } } // TestHTTPServerModeRejectsBadToken 验证上报端 fail-closed:配置了 access_token 时, // 缺失或错误的 token 一律 403,事件绝不进消息流。 func TestHTTPServerModeRejectsBadToken(t *testing.T) { t.Parallel() a := NewAdapter(Config{ Transport: transportHTTP, Mode: modeServer, ListenAddr: "127.0.0.1:0", AccessToken: "secret-token", }) // 缓冲足够大:即便鉴权门失效、两条上报都进来,handler 也不会阻塞在发送上。 got := make(chan adapter.UnifiedMessage, 4) a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) { got <- m }) if err := a.Connect(context.Background()); err != nil { t.Fatalf("Connect error: %v", err) } defer func() { _ = a.Disconnect(context.Background()) }() url := "http://" + a.Addr() + "/" for _, token := range []string{"", "wrong-token"} { if res := postReport(t, url, token, groupEventJSON); res.status != http.StatusForbidden { t.Errorf("token %q status = %d, want 403", token, res.status) } } select { case m := <-got: t.Fatalf("rejected report still reached the handler: %+v", m) case <-time.After(150 * time.Millisecond): } } // TestHTTPBothEndsFullLoop 覆盖 HTTP 双端闭环:同一个适配器既收 OneBot 上报,又能经 // OneBot HTTP API 回消息——OneBot v11 的 HTTP 通信本来就是这两半合起来才闭环。 func TestHTTPBothEndsFullLoop(t *testing.T) { t.Parallel() api := &fakeOneBotAPI{} srv := httptest.NewServer(api.handler()) defer srv.Close() a := NewAdapter(Config{ Transport: transportHTTP, Mode: modeServer, ListenAddr: "127.0.0.1:0", APIBaseURL: srv.URL, }) got := make(chan adapter.UnifiedMessage, 1) a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) { got <- m }) if err := a.Connect(context.Background()); err != nil { t.Fatalf("Connect error: %v", err) } defer func() { _ = a.Disconnect(context.Background()) }() if res := postReport(t, "http://"+a.Addr()+"/", "", groupEventJSON); res.status != http.StatusNoContent { t.Fatalf("report status = %d, want 204", res.status) } select { case <-got: case <-time.After(3 * time.Second): t.Fatal("report never reached the message handler") } if err := a.SendMessage(context.Background(), adapter.UnifiedResponse{ Messages: []adapter.UnifiedMessage{{ChannelType: adapter.ChannelGroup, GroupID: "123", Content: "pong"}}, }); err != nil { t.Fatalf("SendMessage error: %v", err) } reqs := api.snapshot() if len(reqs) != 1 || reqs[0].action != "send_msg" { t.Fatalf("api requests = %+v, want exactly one send_msg", reqs) } assertSendMsg(t, reqs[0].body) } // TestHTTPClientSurfacesAPIFailure 验证 HTTP client 端等响应并判成败:OneBot 回 // retcode!=0 时发送必须报错。v1「写完就报成功」的实现在这里会静默失败,这正是本次 // 改造要修的反例。 func TestHTTPClientSurfacesAPIFailure(t *testing.T) { t.Parallel() api := &fakeOneBotAPI{retCode: 100, status: "failed"} srv := httptest.NewServer(api.handler()) defer srv.Close() a := NewAdapter(Config{Transport: transportHTTP, Mode: modeClient, APIBaseURL: srv.URL}) if err := a.Connect(context.Background()); err != nil { t.Fatalf("Connect error: %v", err) } defer func() { _ = a.Disconnect(context.Background()) }() err := a.SendMessage(context.Background(), adapter.UnifiedResponse{ Messages: []adapter.UnifiedMessage{{ChannelType: adapter.ChannelDirect, ChannelID: "42", Content: "hi"}}, }) if err == nil { t.Fatal("SendMessage returned nil, want the OneBot retcode failure surfaced") } if !strings.Contains(err.Error(), "retcode=100") { t.Errorf("error = %v, want it to name retcode=100", err) } } // TestHTTPModeRequiresItsOwnEndpoint 验证 mode 决定哪一端必需:配置不全时 Connect // 失败,且**绝不留半开的监听端口**(桥接器对 Connect 失败不会回头调用 Disconnect)。 func TestHTTPModeRequiresItsOwnEndpoint(t *testing.T) { t.Parallel() // http+client 缺 api_base_url。 client := NewAdapter(Config{Transport: transportHTTP, Mode: modeClient}) if err := client.Connect(context.Background()); err == nil { t.Fatal("http client mode without apiBaseUrl: Connect() = nil, want error") } _ = client.Disconnect(context.Background()) // http+server 缺 listen_addr:默认绝不监听。 server := NewAdapter(Config{Transport: transportHTTP}) if err := server.Connect(context.Background()); err == nil { t.Fatal("http server mode without listenAddr: Connect() = nil, want error") } if addr := server.Addr(); addr != "" { t.Errorf("failed Connect must not leave a listener, Addr() = %q", addr) } _ = server.Disconnect(context.Background()) // 出站基址非法时先失败、后绑端口:端口不能漏。 leak := NewAdapter(Config{ Transport: transportHTTP, Mode: modeServer, ListenAddr: "127.0.0.1:0", APIBaseURL: "ws://not-http.example:3000", }) if err := leak.Connect(context.Background()); err == nil { t.Fatal("invalid apiBaseUrl: Connect() = nil, want error") } if addr := leak.Addr(); addr != "" { t.Errorf("invalid apiBaseUrl leaked a listener at %q", addr) } _ = leak.Disconnect(context.Background()) // server 端给齐 listen_addr、不给 api_base_url 时仍可启动(只收入站)。 recvOnly := NewAdapter(Config{Transport: transportHTTP, Mode: modeServer, ListenAddr: "127.0.0.1:0"}) if err := recvOnly.Connect(context.Background()); err != nil { t.Fatalf("http server mode with listenAddr: Connect error = %v", err) } if recvOnly.Addr() == "" { t.Error("http server mode: Addr() empty, want the report listener") } _ = recvOnly.Disconnect(context.Background()) } // TestFourEndsConfigDefaultsAndNormalization 验证四端配置项的默认值口径(裁决 1.4): // 不给任何新配置时 transport=ws、mode=server,与改造前完全一致;未知值也不静默改道。 func TestFourEndsConfigDefaultsAndNormalization(t *testing.T) { t.Parallel() base := NewAdapter(Config{}).Config() if base["transport"] != transportWS || base["mode"] != modeServer { t.Errorf("defaults = transport:%v mode:%v, want ws/server", base["transport"], base["mode"]) } if base["reportPath"] != defaultReportPath { t.Errorf("default reportPath = %v, want %q", base["reportPath"], defaultReportPath) } cases := []struct{ in, want string }{ {"", transportWS}, {"WS", transportWS}, {" http ", transportHTTP}, {"tcp", transportWS}, } for _, c := range cases { if got := NewAdapter(Config{Transport: c.in}).Config()["transport"]; got != c.want { t.Errorf("transport %q → %v, want %q", c.in, got, c.want) } } // http+client 的 Config() 必须把 api_base_url 作为远端地址上报,否则 channelbridge // 的凭据门(connect.go:143-147 按 remoteURL 判 client 模式)会把该账号当「无凭据」 // 跳过,HTTP client 端在生产组合根里永远不会连接。 cfg := NewAdapter(Config{ Transport: transportHTTP, Mode: modeClient, APIBaseURL: "http://127.0.0.1:3000", }).Config() if cfg["remoteURL"] != "http://127.0.0.1:3000" { t.Errorf("http client remoteURL = %v, want the api base url", cfg["remoteURL"]) } } // TestNewOBHTTPClientValidatesBaseURL 验证出站基址显式校验:必须 http/https 且带 host, // 不得内嵌凭据(凭据只走 Authorization 头,避免进 URL / 日志 / 错误信息)。 func TestNewOBHTTPClientValidatesBaseURL(t *testing.T) { t.Parallel() for _, bad := range []string{"", "ws://host:6700", "ftp://host", "http://", "http://user:pass@host:3000"} { if _, err := newOBHTTPClient(bad, "tok"); err == nil { t.Errorf("newOBHTTPClient(%q) = nil error, want rejection", bad) } } c, err := newOBHTTPClient("http://127.0.0.1:3000/", "tok") if err != nil { t.Fatalf("newOBHTTPClient valid base error: %v", err) } if c.baseURL != "http://127.0.0.1:3000" { t.Errorf("baseURL = %q, want the trailing slash trimmed", c.baseURL) } } // TestHTTPReportPathAndMethodGuards 验证上报路径归一化、非 POST 拒绝,以及未声明的 // 路径不会被上报端受理。 func TestHTTPReportPathAndMethodGuards(t *testing.T) { t.Parallel() a := NewAdapter(Config{ Transport: transportHTTP, Mode: modeServer, ListenAddr: "127.0.0.1:0", ReportPath: "hook", }) if got := a.Config()["reportPath"]; got != "/hook" { t.Fatalf("reportPath = %v, want /hook", got) } if err := a.Connect(context.Background()); err != nil { t.Fatalf("Connect error: %v", err) } defer func() { _ = a.Disconnect(context.Background()) }() getResp, err := http.Get("http://" + a.Addr() + "/hook") if err != nil { t.Fatalf("GET /hook: %v", err) } defer func() { _ = getResp.Body.Close() }() if getResp.StatusCode != http.StatusMethodNotAllowed { t.Errorf("GET status = %d, want 405", getResp.StatusCode) } other, err := http.Post("http://"+a.Addr()+"/elsewhere", "application/json", strings.NewReader(groupEventJSON)) if err != nil { t.Fatalf("POST /elsewhere: %v", err) } defer func() { _ = other.Body.Close() }() if other.StatusCode != http.StatusNotFound { t.Errorf("POST /elsewhere status = %d, want 404", other.StatusCode) } }