package gateway import ( "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) // 本文件覆盖渠道级角色绑定的管理侧一半(design/22 §2 // 方案 b / §4.4):PUT /admin/channels/{platform}/config 通过 // 既有的 read-modify-write + bridge.Reload 路径接受 personaId, // 未知角色会被拒绝,而不是绑定一个无法应答的角色; // 列表端点会回报该绑定。 // // personaId 在线上是三态的,因为面板仅在主人 // 真正触碰过角色选择时才发送该键: // // 键缺失 → 保留已存绑定 // 显式 "" → 清除绑定 // 非空 id → 绑定该角色(未知 id → 400) // // 「键缺失」这一情形正是静默丢弃绑定的那个,因此用字面量 // JSON 请求体断言:若 DTO 从别处取到了值,map[string]any // 仍会让断言通过。 // putChannelConfigRaw 发送字面量 JSON 请求体,使测试能钉死确切的 // 线上结构(尤其是:完全没有 personaId 键)。 func putChannelConfigRaw(t *testing.T, ts *httptest.Server, platform, raw, token string) (*http.Response, []byte) { t.Helper() req, err := http.NewRequest(http.MethodPut, ts.URL+"/api/v1/admin/channels/"+platform+"/config", bytes.NewReader([]byte(raw))) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do request: %v", err) } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read response: %v", err) } if resp.StatusCode != http.StatusCreated { t.Fatalf("PUT %s status = %d, want 201 (body=%s)", platform, resp.StatusCode, b) } return resp, b } // storedPersonaID 读取 channels.json 为某个账号保存的绑定。 func storedPersonaID(t *testing.T, path, platform, accountID string) string { t.Helper() accounts, warn := runtimeconfig.LoadChannels(path, nil) if warn != nil { t.Fatalf("load channels warn = %v", warn) } for _, acct := range accounts[platform] { if acct.ID == accountID { return acct.PersonaID } } t.Fatalf("account %s/%s not found in channels.json: %+v", platform, accountID, accounts[platform]) return "" } // listedPersonaID 是 GET /admin/channels 为某个账号报告的 personaId, // 即面板读回用于渲染角色选择的值。 func listedPersonaID(t *testing.T, ts *httptest.Server, token, platform, accountID string) (string, []byte) { t.Helper() resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, token) if resp.StatusCode != http.StatusOK { t.Fatalf("get status = %d, want 200 (body=%s)", resp.StatusCode, body) } var listed []struct { Name string `json:"name"` Accounts []struct { ID string `json:"id"` PersonaID string `json:"personaId"` } `json:"accounts"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &listed); err != nil { t.Fatalf("decode channels list: %v (body=%s)", err, body) } for _, entry := range listed { if entry.Name != platform { continue } for _, acct := range entry.Accounts { if acct.ID == accountID { return acct.PersonaID, body } } } t.Fatalf("account %s/%s not in GET /admin/channels (body=%s)", platform, accountID, body) return "", body } // TestAdminSaveChannelConfigPersonaIDBindsAndEchoes 绑定一个渠道角色, // 断言它到达了 channels.json、列表端点回显它供面板渲染, // 并且热重载路径被执行。 func TestAdminSaveChannelConfigPersonaIDBindsAndEchoes(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{}} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config", map[string]any{"id": "conf0", "token": "bot-tok", "personaId": "yuki"}, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("save status = %d, want 201 (body=%s)", resp.StatusCode, body) } if got := storedPersonaID(t, path, "telegram", "conf0"); got != "yuki" { t.Fatalf("channels.json telegram.conf0 personaId = %q, want yuki", got) } if len(store.reloaded) != 1 || store.reloaded[0] != "telegram" { t.Fatalf("reloaded = %v, want [telegram] (the panel's hot-reload path)", store.reloaded) } echoed, listBody := listedPersonaID(t, ts, tok, "telegram", "conf0") if echoed != "yuki" { t.Fatalf("GET /admin/channels telegram.conf0 personaId = %q, want yuki (body=%s)", echoed, listBody) } t.Logf("GET /api/v1/admin/channels echoes the binding: %s", listBody) } // TestAdminSaveChannelConfigPersonaIDKeptWhenOmitted 是静默数据 // 丢失的回归:除非主人触碰过角色选择,面板会省略 personaId, // 因此保存另一个字段必须不碰已存绑定。 func TestAdminSaveChannelConfigPersonaIDKeptWhenOmitted(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ "telegram": {{ID: "conf0", Config: map[string]any{"bot_token": "old-tok"}, PersonaID: "yuki"}}, }} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)}) tok := signAny(t, jwt, "acct", auth.RoleOwner) // 线上请求体携带新 token,且完全没有 personaId 键 —— // 正是一个未被触碰的角色选择所产生的形态。 _, body := putChannelConfigRaw(t, ts, "telegram", `{"id":"conf0","token":"new-tok","groupMsgPerMin":5}`, tok) if got := storedPersonaID(t, path, "telegram", "conf0"); got != "yuki" { t.Fatalf("omitted personaId cleared the binding: channels.json personaId = %q, want yuki (resp=%s)", got, body) } accounts, _ := runtimeconfig.LoadChannels(path, nil) if accounts["telegram"][0].Config["bot_token"] != "new-tok" || accounts["telegram"][0].GroupMsgPerMin != 5 { t.Fatalf("the other fields were not saved: %+v", accounts["telegram"][0]) } if echoed, listBody := listedPersonaID(t, ts, tok, "telegram", "conf0"); echoed != "yuki" { t.Fatalf("GET /admin/channels personaId = %q, want yuki (body=%s)", echoed, listBody) } // 显式的 JSON null 与键缺失相同:DTO 无法区分二者, // 二者都不表示「清除」。 putChannelConfigRaw(t, ts, "telegram", `{"id":"conf0","token":"newer-tok","personaId":null}`, tok) if got := storedPersonaID(t, path, "telegram", "conf0"); got != "yuki" { t.Fatalf("null personaId cleared the binding: personaId = %q, want yuki", got) } } // TestAdminSaveChannelConfigPersonaIDClearedOnExplicitEmpty 断言清除 // 绑定仍然可行,但仅在面板以显式 "" 表示时才如此。 func TestAdminSaveChannelConfigPersonaIDClearedOnExplicitEmpty(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ "telegram": {{ID: "conf0", Config: map[string]any{"bot_token": "old-tok"}, PersonaID: "yuki"}}, }} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)}) tok := signAny(t, jwt, "acct", auth.RoleOwner) putChannelConfigRaw(t, ts, "telegram", `{"id":"conf0","token":"new-tok","personaId":""}`, tok) if got := storedPersonaID(t, path, "telegram", "conf0"); got != "" { t.Fatalf("explicit \"\" personaId = %q, want the binding cleared", got) } if echoed, listBody := listedPersonaID(t, ts, tok, "telegram", "conf0"); echoed != "" { t.Fatalf("GET /admin/channels personaId = %q, want empty (body=%s)", echoed, listBody) } } // TestAdminSaveChannelConfigPersonaIDKeptPerAccount 把 read-modify-write // 钉在正在保存的账号上:某账号省略 personaId 时必须继承该 // 账号自己的绑定,而不是兄弟账号的。 func TestAdminSaveChannelConfigPersonaIDKeptPerAccount(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ "telegram": { {ID: "conf0", Config: map[string]any{"bot_token": "a"}, PersonaID: "yuki"}, {ID: "conf1", Config: map[string]any{"bot_token": "b"}}, }, }} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)}) tok := signAny(t, jwt, "acct", auth.RoleOwner) putChannelConfigRaw(t, ts, "telegram", `{"id":"conf1","token":"b2"}`, tok) if got := storedPersonaID(t, path, "telegram", "conf0"); got != "yuki" { t.Fatalf("sibling account conf0 personaId = %q, want yuki untouched", got) } if got := storedPersonaID(t, path, "telegram", "conf1"); got != "" { t.Fatalf("conf1 personaId = %q, want empty (it never had a binding)", got) } } // TestAdminSaveChannelConfigRejectsUnknownPersonaID 保持保存 fail-closed: // 绑定到一个库不认识的角色时,渠道会静默地以默认角色应答, // 因此该绑定被拒绝,不写入也不重载。 func TestAdminSaveChannelConfigRejectsUnknownPersonaID(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ "telegram": {{ID: "conf0", Config: map[string]any{"bot_token": "old-tok"}, PersonaID: "yuki"}}, }} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store, Personas: personaTestRegistry(t)}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config", map[string]any{"id": "conf0", "token": "bot-tok", "personaId": "nobody"}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("unknown personaId status = %d, want 400 (body=%s)", resp.StatusCode, body) } if !strings.Contains(string(body), "personaId") { t.Fatalf("rejection body = %s, want it to name personaId", body) } if len(store.reloaded) != 0 { t.Fatalf("reloaded = %v, want none for a refused save", store.reloaded) } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("a refused save must not write channels.json (stat err = %v)", err) } }