package gateway import ( "context" "encoding/json" "net/http" "strings" "sync/atomic" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // 本文件锁定「连接到底连了谁」这一根本困惑: // // - 带 accountId 的 open/close **只**作用于该账号; // - 找不到该 id 时明确报错(404 account_not_found / 409 account_not_registered), // **绝不静默回落到第一条**(静默回落正是主人点「连接」打到历史条目的根因); // - 不带 accountId 时维持老契约,且在多账号平台上按**配置顺序**确定目标 // (注册表是 map,旧代码的「第一条」其实每次可能不同,面板无法如实标注)。 // spyChannel 记录 Connect/Disconnect 是否真被调用,使「面板点的那一条被驱动了、 // 另一条没被碰」成为可断言的证据。 type spyChannel struct { name string connects atomic.Int32 disconns atomic.Int32 connected atomic.Bool connectErr error } func (s *spyChannel) Name() string { return s.name } func (s *spyChannel) IsConnected() bool { return s.connected.Load() } func (s *spyChannel) HealthCheck(context.Context) error { return nil } func (s *spyChannel) Connect(context.Context) error { s.connects.Add(1) if s.connectErr != nil { return s.connectErr } s.connected.Store(true) return nil } func (s *spyChannel) Disconnect(context.Context) error { s.disconns.Add(1) s.connected.Store(false) return nil } // twoAccountFixture 建一个「两条 qq-obv11」的现场:一条无 id 的历史条目 // (裸适配器名)与一条主人新建的 client 账号,并返回 spy 句柄。 func twoAccountFixture(t *testing.T, store *stubChannelConfigStore) (*spyChannel, *spyChannel, string, *auth.JWTService) { t.Helper() legacy := &spyChannel{name: "qq-obv11"} fresh := &spyChannel{name: "qq-obv11:9616b27a"} caps := capreg.New() if err := caps.RegisterChannel(legacy); err != nil { t.Fatalf("register legacy: %v", err) } if err := caps.RegisterChannel(fresh); err != nil { t.Fatalf("register fresh: %v", err) } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store}) return legacy, fresh, ts.URL, jwt } // worktreeTestStore 返回一个临时 channels.json 存储,含该平台的给定账号。 func channelsStoreOf(t *testing.T, platform string, accts ...config.ChannelAccount) *stubChannelConfigStore { t.Helper() return &stubChannelConfigStore{ path: t.TempDir() + "/channels.json", accounts: map[string][]config.ChannelAccount{platform: accts}, } } // TestAdminChannelOpenTargetsRequestedAccount 断言 accountId 给出时连的是它, // 而不是该平台注册表里的第一条。 func TestAdminChannelOpenTargetsRequestedAccount(t *testing.T) { t.Parallel() legacy, fresh, base, jwt := twoAccountFixture(t, nil) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, base+"/api/v1/admin/channels/open", map[string]any{"name": "qq-obv11", "accountId": "9616b27a"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("open status = %d, want 200 (body=%s)", resp.StatusCode, body) } if got := fresh.connects.Load(); got != 1 { t.Fatalf("fresh account Connect calls = %d, want 1", got) } if got := legacy.connects.Load(); got != 0 { t.Fatalf("legacy account Connect calls = %d, want 0(不得打到别条账号)", got) } var res struct { Name string `json:"name"` Resolved string `json:"resolved"` AccountID string `json:"accountId"` Connected bool `json:"connected"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { t.Fatalf("decode open response: %v (body=%s)", err, body) } // resolved 必须如实报出**被驱动**的适配器:面板靠它显示「连的是哪一条」。 if res.Resolved != "qq-obv11:9616b27a" || res.AccountID != "9616b27a" || !res.Connected { t.Fatalf("open response = %+v, want resolved=qq-obv11:9616b27a accountId=9616b27a connected=true", res) } } // TestAdminChannelCloseTargetsRequestedAccount 同上,针对 close。 func TestAdminChannelCloseTargetsRequestedAccount(t *testing.T) { t.Parallel() legacy, fresh, base, jwt := twoAccountFixture(t, nil) fresh.connected.Store(true) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, base+"/api/v1/admin/channels/close", map[string]any{"name": "qq-obv11", "accountId": "9616b27a"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("close status = %d, want 200 (body=%s)", resp.StatusCode, body) } if fresh.disconns.Load() != 1 || legacy.disconns.Load() != 0 { t.Fatalf("disconnect calls fresh=%d legacy=%d, want 1/0", fresh.disconns.Load(), legacy.disconns.Load()) } if fresh.IsConnected() { t.Fatal("fresh account still connected after close") } } // TestAdminChannelOpenUnknownAccountIDIs404 断言「找不到该 id」是明确错误, // 且**绝不**把请求打到该平台的第一条账号(静默回落是本 bug 的根因)。 func TestAdminChannelOpenUnknownAccountIDIs404(t *testing.T) { t.Parallel() store := channelsStoreOf(t, "qq-obv11", config.ChannelAccount{ID: "9616b27a", Config: map[string]any{"mode": "client"}}) legacy, fresh, base, jwt := twoAccountFixture(t, store) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, base+"/api/v1/admin/channels/open", map[string]any{"name": "qq-obv11", "accountId": "does-not-exist"}, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("status = %d, want 404 (body=%s)", resp.StatusCode, body) } errObj := decodeAdminError(t, body) if errObj.Code != "account_not_found" { t.Fatalf("error code = %q, want account_not_found", errObj.Code) } // 错误文案必须带上可用账号,方便面板/调用方自行纠正(账号 id 非敏感)。 if !strings.Contains(errObj.Message, "9616b27a") { t.Fatalf("error message = %q, want it to list the known account id", errObj.Message) } if legacy.connects.Load() != 0 || fresh.connects.Load() != 0 { t.Fatalf("no account may be connected on a miss: legacy=%d fresh=%d", legacy.connects.Load(), fresh.connects.Load()) } } // TestAdminChannelOpenConfiguredButUnregisteredIs409 区分两种 miss:账号确实在 // channels.json 里、但当前没有它的活适配器(多半缺必要凭据),给 409 而不是 404, // 面板据此提示「去修该账号配置」而不是「账号不存在」。 func TestAdminChannelOpenConfiguredButUnregisteredIs409(t *testing.T) { t.Parallel() store := channelsStoreOf(t, "qq-obv11", config.ChannelAccount{ID: "9616b27a", Config: map[string]any{"mode": "client"}}) caps := capreg.New() legacy := &spyChannel{name: "qq-obv11"} if err := caps.RegisterChannel(legacy); err != nil { t.Fatalf("register: %v", err) } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open", map[string]any{"name": "qq-obv11", "accountId": "9616b27a"}, tok) if resp.StatusCode != http.StatusConflict { t.Fatalf("status = %d, want 409 (body=%s)", resp.StatusCode, body) } if code := decodeAdminError(t, body).Code; code != "account_not_registered" { t.Fatalf("error code = %q, want account_not_registered", code) } if legacy.connects.Load() != 0 { t.Fatal("unregistered account must not fall back to another account") } } // TestAdminChannelOpenWithoutAccountIDStaysLegacy 断言缺省 accountId 时仍是老契约: // 单账号平台照旧连上那一条,且响应不凭空造出 accountId。 func TestAdminChannelOpenWithoutAccountIDStaysLegacy(t *testing.T) { t.Parallel() caps := capreg.New() only := &spyChannel{name: "kook:3fc8"} if err := caps.RegisterChannel(only); err != nil { t.Fatalf("register: %v", err) } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open", map[string]any{"name": "kook"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } if only.connects.Load() != 1 { t.Fatalf("connect calls = %d, want 1", only.connects.Load()) } var out map[string]any if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil { t.Fatalf("decode: %v (body=%s)", err, body) } if _, present := out["accountId"]; present { t.Fatalf("platform-level open must not invent an accountId: %v", out) } if out["name"] != "kook" || out["resolved"] != "kook:3fc8" { t.Fatalf("open data = %v, want name=kook resolved=kook:3fc8", out) } } // TestAdminChannelOpenPlatformLevelIsDeterministic 断言多账号平台上的**平台级** // 连接目标按配置顺序确定,而不是随注册表 map 顺序抖动 —— 否则面板无法如实标注 // 「这次会连哪一条」,主人也就永远不知道平台级按钮会打到谁。 func TestAdminChannelOpenPlatformLevelIsDeterministic(t *testing.T) { t.Parallel() caps := capreg.New() first := &spyChannel{name: "qq-obv11:aaaa1111"} second := &spyChannel{name: "qq-obv11:bbbb2222"} for _, ch := range []*spyChannel{second, first} { // 注册顺序与配置顺序故意相反 if err := caps.RegisterChannel(ch); err != nil { t.Fatalf("register: %v", err) } } store := channelsStoreOf(t, "qq-obv11", config.ChannelAccount{ID: "aaaa1111", Config: map[string]any{"mode": "client"}}, config.ChannelAccount{ID: "bbbb2222", Config: map[string]any{"mode": "client"}}, ) _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) for i := 0; i < 12; i++ { resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open", map[string]any{"name": "qq-obv11"}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } var out map[string]any if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil { t.Fatalf("decode: %v", err) } if out["resolved"] != "qq-obv11:aaaa1111" { t.Fatalf("round %d resolved = %v, want the first configured account (aaaa1111)", i, out["resolved"]) } } if first.connects.Load() != 12 || second.connects.Load() != 0 { t.Fatalf("connects first=%d second=%d, want 12/0", first.connects.Load(), second.connects.Load()) } } // TestAdminChannelOpenDecodeFailureStill400 保证老客户的坏请求体照旧 400。 func TestAdminChannelOpenDecodeFailureStill400(t *testing.T) { t.Parallel() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doRaw(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open", "{", tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } } // decodeAdminError 解出 {error:{code,message}} 信封。 func decodeAdminError(t *testing.T, body []byte) struct { Code string `json:"code"` Message string `json:"message"` } { t.Helper() var out struct { Code string `json:"code"` Message string `json:"message"` } raw := decodeBody(t, body)["error"] if err := json.Unmarshal(raw, &out); err != nil { t.Fatalf("decode error envelope: %v (body=%s)", err, body) } return out }