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" ) // This file covers the admin half of the channel-level role binding (design/22 §2 // 方案 b / §4.4): PUT /admin/channels/{platform}/config accepts personaId through the // existing read-modify-write + bridge.Reload path, an unknown role is refused // instead of binding a role that cannot answer, and the list endpoint reports the // binding back. // // personaId is three-state on the wire, because the panel sends the key only when // the owner actually touched the role select: // // key absent → keep the stored binding // explicit "" → clear the binding // non-empty id → bind that role (unknown id → 400) // // The absent-key case is the one that silently dropped bindings, so it is asserted // with a literal JSON body: a map[string]any would still make the assertion pass if // the DTO had picked up a value from somewhere else. // putChannelConfigRaw posts a literal JSON body so a test can pin the exact wire // shape (notably: no personaId key at all). 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 reads the binding channels.json holds for one account. 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 is the personaId GET /admin/channels reports for one account, i.e. // what the panel reads back to render the role select. 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 binds a channel role and // asserts it reached channels.json, that the list endpoint echoes it back for the // panel to render, and that the hot-reload path ran. 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 is the regression for the // silent data loss: the panel omits personaId unless the owner touched the role // select, so saving another field must leave the stored binding alone. 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) // The wire body carries the new token and no personaId key at all — exactly // what an untouched role select produces. _, 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) } // An explicit JSON null is the same as an absent key: the DTO cannot tell them // apart, and neither means "clear". 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 asserts that clearing // the binding still works, but only when the panel says so with an explicit "". 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 pins the read-modify-write to // the account being saved: an omitted personaId on one account must inherit that // account's binding, not a sibling's. 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 keeps the save fail-closed: a // channel bound to a role the library does not know would silently answer as the // default role, so it is refused and nothing is written or reloaded. 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) } }