package gateway import ( "encoding/json" "net/http" "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. // TestAdminSaveChannelConfigPersonaID saves a channel role, asserts it reached // channels.json and the list wire shape, then clears it. func TestAdminSaveChannelConfigPersonaID(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) } accounts, warn := runtimeconfig.LoadChannels(path, nil) if warn != nil { t.Fatalf("load channels warn = %v", warn) } if len(accounts["telegram"]) != 1 || accounts["telegram"][0].PersonaID != "yuki" { t.Fatalf("channels.json telegram = %+v, want personaId yuki", accounts["telegram"]) } if len(store.reloaded) != 1 || store.reloaded[0] != "telegram" { t.Fatalf("reloaded = %v, want [telegram] (the panel's hot-reload path)", store.reloaded) } // The list endpoint reports the binding so the panel can render it. var listed []struct { Name string `json:"name"` Accounts []struct { ID string `json:"id"` PersonaID string `json:"personaId"` } `json:"accounts"` } _, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok) if err := json.Unmarshal(decodeBody(t, gBody)["data"], &listed); err != nil { t.Fatalf("decode channels list: %v (body=%s)", err, gBody) } var accts []struct { ID string `json:"id"` PersonaID string `json:"personaId"` } for _, entry := range listed { if entry.Name == "telegram" { accts = entry.Accounts } } if len(accts) != 1 || accts[0].PersonaID != "yuki" { t.Fatalf("GET /admin/channels telegram accounts = %+v, want personaId yuki (body=%s)", accts, gBody) } // Saving the same account without a personaId unbinds it. resp, body = doReq(t, http.MethodPut, ts.URL+"/api/v1/admin/channels/telegram/config", map[string]any{"id": "conf0", "token": "bot-tok"}, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("unbind status = %d, want 201 (body=%s)", resp.StatusCode, body) } accounts, _ = runtimeconfig.LoadChannels(path, nil) if len(accounts["telegram"]) != 1 || accounts["telegram"][0].PersonaID != "" { t.Fatalf("after unbind = %+v, want personaId cleared", accounts["telegram"]) } } // 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{}} _, 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) } }