From b61463343252299d10423d25b45b635c381bf895 Mon Sep 17 00:00:00 2001 From: Aska Date: Mon, 7 Sep 2026 16:01:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20KOOK=20=E8=BF=9E=E4=B8=8D=E4=B8=8A?= =?UTF-8?q?=E6=A0=B9=E5=9B=A0=E2=80=94=E2=80=94=E7=A9=BAtoken=E8=A3=B8?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E4=B8=8D=E6=B3=A8=E5=86=8C/=E5=8D=A0?= =?UTF-8?q?=E5=90=8D=20+=20open=E6=8C=87=E5=90=91=E5=B8=A6token=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=20+=20=E5=89=8D=E7=AB=AFtoken=E4=B8=8D=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=9C=9F=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-119 后端: - buildAccountAdapter: token平台(kook/telegram/weixin)BotToken空→跳过build(不注册不占名), channelAccountsFromEnv 无tokenenv不合成账号(杜绝裸kook id="") - runtimeconfig cleanAccounts: LoadChannels/SaveChannels 丢弃token平台空token账号(清理channels.json现有裸kook, 下次保存抹掉) - findChannel: 裸平台名(kook)回退到该平台第一个带token账号, open"kook"不再命中裸的missing token 我补 前端token掩码不就覆真值: - openConfig 不再把'(已配置,仅掩码显示)'当token回填(setToken空)→save token空不写bot_token保留真值 - 加 tokenHint(显示'已配置留空不改'/重填换); 修之前channels.json kook:3fc8 token变掩码串的bug 全仓 build/vet/test绿+前端build绿(463行) --- cmd/cyrene/channels.go | 68 ++++++++++++------- cmd/cyrene/channels_test.go | 52 ++++++++++++++ frontend/web/src/admin/modules/Channels.tsx | 11 ++- internal/config/runtimeconfig/channels.go | 64 ++++++++++++++++- .../config/runtimeconfig/channels_test.go | 66 ++++++++++++++++++ .../gateway/admin_write_llm_channels.go | 19 +++++- internal/services/gateway/findchannel_test.go | 64 +++++++++++++++++ 7 files changed, 314 insertions(+), 30 deletions(-) create mode 100644 internal/services/gateway/findchannel_test.go diff --git a/cmd/cyrene/channels.go b/cmd/cyrene/channels.go index 5c9f0b1..996a329 100644 --- a/cmd/cyrene/channels.go +++ b/cmd/cyrene/channels.go @@ -19,6 +19,7 @@ import ( "log/slog" "net/http" "os" + "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" @@ -84,25 +85,33 @@ func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log * } // channelAccountsFromEnv synthesizes the env fallback: the single-account -// CHANNEL__* adapters (always present so the channel list stays stable and -// an unset token simply stays disconnected) plus any CHANNELS_JSON multi-account -// entries, which coexist with the single-account adapters as before. It is the -// .env fallback passed to runtimeconfig.LoadChannels when channels.json is absent -// or invalid. +// CHANNEL__* adapters plus any CHANNELS_JSON multi-account entries. It is +// the .env fallback passed to runtimeconfig.LoadChannels when channels.json is +// absent or invalid. +// +// A token-requiring platform (kook/telegram/weixin-ilink) is synthesized only +// when its token env var is set; with an unset token there is no account at all, +// so an empty-token placeholder never claims the bare platform name. qq-obv11 and +// webhook are always present (they do not need a token; the bridge's configured() +// gate decides whether to dial) so the channel list stays stable. func channelAccountsFromEnv() map[string][]config.ChannelAccount { out := map[string][]config.ChannelAccount{} - out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{ - "bot_token": os.Getenv("CHANNEL_KOOK_TOKEN"), - "base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"), - "receive_mode": os.Getenv("CHANNEL_KOOK_RECEIVE_MODE"), - "webhook_path": os.Getenv("CHANNEL_KOOK_WEBHOOK_PATH"), - "webhook_verify_token": os.Getenv("CHANNEL_KOOK_WEBHOOK_VERIFY_TOKEN"), - "webhook_encrypt_key": os.Getenv("CHANNEL_KOOK_WEBHOOK_ENCRYPT_KEY"), - }}) - out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{ - "bot_token": os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"), - "webhook_url": os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"), - }}) + if tok := os.Getenv("CHANNEL_KOOK_TOKEN"); tok != "" { + out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{ + "bot_token": tok, + "base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"), + "receive_mode": os.Getenv("CHANNEL_KOOK_RECEIVE_MODE"), + "webhook_path": os.Getenv("CHANNEL_KOOK_WEBHOOK_PATH"), + "webhook_verify_token": os.Getenv("CHANNEL_KOOK_WEBHOOK_VERIFY_TOKEN"), + "webhook_encrypt_key": os.Getenv("CHANNEL_KOOK_WEBHOOK_ENCRYPT_KEY"), + }}) + } + if tok := os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"); tok != "" { + out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{ + "bot_token": tok, + "webhook_url": os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"), + }}) + } out["qq-obv11"] = append(out["qq-obv11"], config.ChannelAccount{Config: map[string]any{ "mode": envString("CHANNEL_QQ_OBV11_MODE", "server"), "listen_addr": os.Getenv("CHANNEL_QQ_OBV11_LISTEN_ADDR"), @@ -117,13 +126,15 @@ func channelAccountsFromEnv() map[string][]config.ChannelAccount { "secret": os.Getenv("CHANNEL_WEBHOOK_SECRET"), "signatureHeader": os.Getenv("CHANNEL_WEBHOOK_SIGNATURE_HEADER"), }}) - out["weixin-ilink"] = append(out["weixin-ilink"], config.ChannelAccount{Config: map[string]any{ - "base_url": os.Getenv("CHANNEL_WEIXIN_ILINK_BASE_URL"), - "cdn_base_url": os.Getenv("CHANNEL_WEIXIN_ILINK_CDN_BASE_URL"), - "bot_token": os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_TOKEN"), - "bot_id": os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_ID"), - "owner_id": os.Getenv("CHANNEL_WEIXIN_ILINK_OWNER_ID"), - }}) + if tok := os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_TOKEN"); tok != "" { + out["weixin-ilink"] = append(out["weixin-ilink"], config.ChannelAccount{Config: map[string]any{ + "base_url": os.Getenv("CHANNEL_WEIXIN_ILINK_BASE_URL"), + "cdn_base_url": os.Getenv("CHANNEL_WEIXIN_ILINK_CDN_BASE_URL"), + "bot_token": tok, + "bot_id": os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_ID"), + "owner_id": os.Getenv("CHANNEL_WEIXIN_ILINK_OWNER_ID"), + }}) + } if raw := os.Getenv("CHANNELS_JSON"); raw != "" { if accounts, err := config.ParseChannelAccounts(raw); err == nil { for platform, accts := range accounts { @@ -206,12 +217,18 @@ func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog. if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { return nil, fmt.Errorf("decode telegram config: %w", err) } + if strings.TrimSpace(cfg.BotToken) == "" { + return nil, fmt.Errorf("%s: missing bot token", platform) + } inner = telegram.New(cfg, telegram.WithLogger(log)) case "weixin-ilink": var cfg weixin_ilink.Config if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { return nil, fmt.Errorf("decode weixin-ilink config: %w", err) } + if strings.TrimSpace(cfg.BotToken) == "" { + return nil, fmt.Errorf("%s: missing bot token", platform) + } w, err := weixin_ilink.New(cfg, nil, logger.WithSource(log, "plugins/channels/weixin_ilink")) if err != nil { return nil, fmt.Errorf("build weixin-ilink: %w", err) @@ -228,6 +245,9 @@ func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog. if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil { return nil, fmt.Errorf("decode kook config: %w", err) } + if strings.TrimSpace(cfg.BotToken) == "" { + return nil, fmt.Errorf("%s: missing bot token", platform) + } inner = kook.New(cfg, kook.WithLogger(log)) case "webhook": var cfg webhook.Config diff --git a/cmd/cyrene/channels_test.go b/cmd/cyrene/channels_test.go index 4e03e1b..380d0de 100644 --- a/cmd/cyrene/channels_test.go +++ b/cmd/cyrene/channels_test.go @@ -51,6 +51,58 @@ func TestBuildChannelAdapters_KookMultiAccount(t *testing.T) { } } +// TestBuildChannelAdapters_SkipsEmptyTokenKookFromFile verifies a stale +// channels.json with both a bare empty-token kook and a real token account builds +// only the token account, so the bare "kook" never registers (and never wins a +// bare-name open). +func TestBuildChannelAdapters_SkipsEmptyTokenKookFromFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "channels.json") + raw := `{"kook":[ + {"id":"","config":{"base_url":"","bot_token":""}}, + {"id":"3fc8","config":{"bot_token":"real-token"}} + ]}` + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()} + adapters := buildChannelAdapters(f) + if hasAdapter(adapters, "kook") { + t.Fatalf("bare empty-token kook should not be built, got %v", names(adapters)) + } + if !hasAdapter(adapters, "kook:3fc8") { + t.Fatalf("expected kook:3fc8, got %v", names(adapters)) + } +} + +// TestChannelAccountsFromEnv_NoEmptyTokenPlaceholders verifies the env fallback +// does not synthesize an empty-token kook/telegram/weixin account (only a real +// token produces the account), so a bare placeholder is never registered. +func TestChannelAccountsFromEnv_NoEmptyTokenPlaceholders(t *testing.T) { + for _, k := range []string{"CHANNEL_KOOK_TOKEN", "CHANNEL_TELEGRAM_BOT_TOKEN", "CHANNEL_WEIXIN_ILINK_BOT_TOKEN", "CHANNELS_JSON"} { + t.Setenv(k, "") + } + env := channelAccountsFromEnv() + for _, platform := range []string{"kook", "telegram", "weixin-ilink"} { + if _, ok := env[platform]; ok { + t.Fatalf("empty-token %s should not be synthesized", platform) + } + } +} + +// TestChannelAccountsFromEnv_KookPresentWhenTokenSet verifies a set token yields +// the account. +func TestChannelAccountsFromEnv_KookPresentWhenTokenSet(t *testing.T) { + t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret") + env := channelAccountsFromEnv() + list, ok := env["kook"] + if !ok || len(list) != 1 { + t.Fatalf("expected a synthesized kook account, got %+v", env["kook"]) + } + if list[0].Config["bot_token"] != "kook-secret" { + t.Fatalf("bot_token = %v, want kook-secret", list[0].Config["bot_token"]) + } +} + func hasAdapter(adapters []adapter.PlatformAdapter, name string) bool { for _, a := range adapters { if a.Name() == name { diff --git a/frontend/web/src/admin/modules/Channels.tsx b/frontend/web/src/admin/modules/Channels.tsx index 9b7ceb6..a7c6fa7 100644 --- a/frontend/web/src/admin/modules/Channels.tsx +++ b/frontend/web/src/admin/modules/Channels.tsx @@ -56,6 +56,9 @@ export function ChannelsModule(): React.ReactNode { // Business-policy fold (blacklist/allow + rate limits) is separate from the // connection config — it governs admission/de-noise, not the endpoint. const [polOpen, setPolOpen] = useState(false); + // Hint shown next to the Token field when an account already has a secret + // (masked): the token input stays empty so a save does NOT overwrite it. + const [tokenHint, setTokenHint] = useState(""); const toggle = (name: string, connect: boolean) => { setBusy(name); @@ -103,7 +106,12 @@ export function ChannelsModule(): React.ReactNode { const acct = (data ?? []).find((c) => platformOf(c.name) === platform)?.accounts?.[0]; setCfgId(acct?.id ?? ""); const cfg = acct?.config ?? {}; - setToken(typeof cfg.bot_token === "string" && cfg.bot_token ? "(已配置,仅掩码显示)" : ""); + // Do NOT put the masked placeholder into `token`: it would be written back as + // the real bot_token (overwriting the stored secret with a demo string). Keep + // token empty (save omits it → keeps the existing secret) and show a hint. + const hasToken = typeof cfg.bot_token === "string" && cfg.bot_token && !cfg.bot_token.includes("(已配置"); + setToken(""); + setTokenHint(hasToken ? "已配置(仅掩码显示),留空则不改动;如需更换请重新输入。" : ""); setBaseURL(typeof cfg.base_url === "string" ? cfg.base_url : ""); const plat: Record = {}; for (const f of PLATFORM_FIELDS[platform] ?? []) { @@ -318,6 +326,7 @@ export function ChannelsModule(): React.ReactNode { ) : null} setToken(e.target.value)} placeholder="机器人 token" className={`${inputCls} border border-[var(--mu-accent)]/25`} /> + {tokenHint ? {tokenHint} : null} {PLATFORM_GUIDE[cfgPlatform].hint ? ( diff --git a/internal/config/runtimeconfig/channels.go b/internal/config/runtimeconfig/channels.go index 45f4ee5..3e41693 100644 --- a/internal/config/runtimeconfig/channels.go +++ b/internal/config/runtimeconfig/channels.go @@ -40,11 +40,14 @@ func DefaultChannelsPath(dataDir string) string { // present-and-valid channels.json it is authoritative; otherwise it falls back // to envFallback (the .env/CHANNELS_JSON channel block computed by the caller). // A present-but-invalid file falls back to env and returns a non-nil warning so -// the caller can log it. The returned map is non-nil. +// the caller can log it. The returned map is non-nil. A file-loaded account list +// is cleaned (bare empty-token placeholders dropped) so a stale channels.json +// never surfaces an account that cannot connect; the caller's envFallback is +// left untouched. func LoadChannels(path string, envFallback map[string][]config.ChannelAccount) (map[string][]config.ChannelAccount, error) { accounts, readErr := loadChannelsFile(path) if readErr == nil { - return accounts, nil + return cleanAccounts(accounts), nil } var warn error if !os.IsNotExist(readErr) { @@ -80,11 +83,13 @@ func loadChannelsFile(path string) (map[string][]config.ChannelAccount, error) { // SaveChannels writes accounts as a pretty-printed JSON map to path with mode // 0600 (the file holds bot tokens). It writes to a temp file then renames so a -// concurrent reader never sees a partial file. +// concurrent reader never sees a partial file. Bare empty-token placeholders are +// dropped before writing, so an unusable account never lands in the file. func SaveChannels(path string, accounts map[string][]config.ChannelAccount) error { if accounts == nil { accounts = map[string][]config.ChannelAccount{} } + accounts = cleanAccounts(accounts) dir := filepath.Dir(path) if dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { @@ -204,3 +209,56 @@ func maskToken(s string) string { } return "****" + s[len(s)-4:] } + +// tokenRequiringPlatforms are the adapters whose connection needs a bot token +// (design/08 §4). An account for one of these with an empty bot_token is a bare +// placeholder: it can never connect and must not claim the bare platform name +// (design/16 §4.5), so it is dropped from the effective config and from +// channels.json. Other platforms (qq-obv11, webhook) are left alone so their +// channel list stays stable while the bridge's configured() gate decides whether +// to dial. +func tokenRequiringPlatforms(platform string) bool { + switch platform { + case "kook", "telegram", "weixin-ilink": + return true + default: + return false + } +} + +// cleanAccounts drops unusable bare accounts from an accounts map. For a +// token-requiring platform any account with an empty bot_token is dropped (a +// bare "kook"/"telegram"/"weixin-ilink" placeholder can never connect). It never +// logs or returns a token. +func cleanAccounts(accounts map[string][]config.ChannelAccount) map[string][]config.ChannelAccount { + if accounts == nil { + return accounts + } + out := make(map[string][]config.ChannelAccount, len(accounts)) + for platform, list := range accounts { + if !tokenRequiringPlatforms(platform) { + out[platform] = list + continue + } + kept := make([]config.ChannelAccount, 0, len(list)) + for _, a := range list { + if strings.TrimSpace(accountBotToken(a)) == "" { + continue + } + kept = append(kept, a) + } + if len(kept) > 0 { + out[platform] = kept + } + } + return out +} + +// accountBotToken returns the account's bot_token value (the credential the +// token-requiring platforms need). It never logs the token. +func accountBotToken(a config.ChannelAccount) string { + if s, ok := a.Config["bot_token"].(string); ok { + return s + } + return "" +} diff --git a/internal/config/runtimeconfig/channels_test.go b/internal/config/runtimeconfig/channels_test.go index 5900fc2..9dbc93e 100644 --- a/internal/config/runtimeconfig/channels_test.go +++ b/internal/config/runtimeconfig/channels_test.go @@ -90,6 +90,72 @@ func TestLoadChannelsEmptyMapFallsBackWithWarn(t *testing.T) { } } +func TestLoadChannelsCleansEmptyTokenKook(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "channels.json") + // A stale channels.json holding a bare empty-token kook plus a real account. + raw := `{"kook":[ + {"id":"","config":{"base_url":"","bot_token":""}}, + {"id":"3fc8ef9434bddfbbe4f4daa8da21c093","config":{"bot_token":"real-token"}} + ]}` + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + accounts, warn := LoadChannels(path, nil) + if warn != nil { + t.Fatalf("warn = %v, want nil", warn) + } + kook := accounts["kook"] + if len(kook) != 1 { + t.Fatalf("kook = %+v, want only the token-bearing account", kook) + } + if kook[0].ID != "3fc8ef9434bddfbbe4f4daa8da21c093" || kook[0].Config["bot_token"] != "real-token" { + t.Fatalf("kook = %+v, want the token-bearing account", kook[0]) + } +} + +func TestLoadChannelsKeepsNonTokenPlatforms(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "channels.json") + // qq-obv11/webhook carry no bot token and must be preserved. + raw := `{"qq-obv11":[{"id":"","config":{"mode":"server"}}],"webhook":[{"id":"","config":{"endpointPath":""}}]}` + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + accounts, warn := LoadChannels(path, nil) + if warn != nil { + t.Fatalf("warn = %v", warn) + } + if len(accounts["qq-obv11"]) != 1 || len(accounts["webhook"]) != 1 { + t.Fatalf("non-token platforms should be preserved: %+v", accounts) + } +} + +func TestSaveChannelsDropsEmptyTokenBareAccount(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "channels.json") + accts := map[string][]config.ChannelAccount{ + "kook": { + {ID: "", Config: map[string]any{"bot_token": ""}}, + {ID: "a", Config: map[string]any{"bot_token": "tk"}}, + }, + "webhook": {{ID: "", Config: map[string]any{"endpointPath": ""}}}, + } + if err := SaveChannels(path, accts); err != nil { + t.Fatalf("save: %v", err) + } + got, warn := LoadChannels(path, nil) + if warn != nil { + t.Fatalf("load warn = %v", warn) + } + if len(got["kook"]) != 1 || got["kook"][0].ID != "a" { + t.Fatalf("kook = %+v, want only the token-bearing account", got["kook"]) + } + if _, ok := got["webhook"]; !ok || len(got["webhook"]) != 1 { + t.Fatalf("webhook should be preserved untouched: %+v", got["webhook"]) + } +} + func TestSaveChannelsRoundTrip(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "channels.json") diff --git a/internal/services/gateway/admin_write_llm_channels.go b/internal/services/gateway/admin_write_llm_channels.go index 8c5fdb9..c6d3132 100644 --- a/internal/services/gateway/admin_write_llm_channels.go +++ b/internal/services/gateway/admin_write_llm_channels.go @@ -283,15 +283,30 @@ func (s *Server) channelName(r *http.Request, w http.ResponseWriter, op string) return req.Name, true } -// findChannel returns the registered channel adapter named name, if any. +// findChannel returns the registered channel adapter named name, if any. An exact +// adapter name ("kook:conf0") wins; a bare platform name ("kook") that is not an +// exact name still resolves to that platform's first registered account, so the +// panel/CLI "connect kook" reaches the token-bearing account even when it is named +// "kook:conf0" (the empty-token bare placeholder is never registered). func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) { if caps == nil { return nil, false } + var first sdk.Channel for _, cap := range caps.List(capreg.KindChannel) { - if ch, ok := cap.Value.(sdk.Channel); ok && ch.Name() == name { + ch, ok := cap.Value.(sdk.Channel) + if !ok { + continue + } + if ch.Name() == name { return ch, true } + if first == nil && channelBasePlatform(ch.Name()) == name { + first = ch + } + } + if first != nil { + return first, true } return nil, false } diff --git a/internal/services/gateway/findchannel_test.go b/internal/services/gateway/findchannel_test.go new file mode 100644 index 0000000..23a65ab --- /dev/null +++ b/internal/services/gateway/findchannel_test.go @@ -0,0 +1,64 @@ +package gateway + +import ( + "context" + "testing" + + capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" +) + +// testChannel is a bare sdk.Channel used to exercise findChannel resolution. +type testChannel struct{ name string } + +func (t testChannel) Name() string { return t.name } +func (t testChannel) Connect(context.Context) error { + return nil +} +func (t testChannel) Disconnect(context.Context) error { return nil } +func (t testChannel) IsConnected() bool { return false } +func (t testChannel) HealthCheck(context.Context) error { return nil } + +// TestFindChannelExactMatch prefers an exact adapter name over a bare-name match. +func TestFindChannelExactMatch(t *testing.T) { + reg := capreg.New() + _ = reg.Register(capreg.Capability{Name: "kook:conf0", Kind: capreg.KindChannel, Value: testChannel{name: "kook:conf0"}}) + _ = reg.Register(capreg.Capability{Name: "telegram:conf0", Kind: capreg.KindChannel, Value: testChannel{name: "telegram:conf0"}}) + ch, ok := findChannel(reg, "kook:conf0") + if !ok || ch.Name() != "kook:conf0" { + t.Fatalf("exact kook:conf0 not found, got %v/%v", ch, ok) + } + ch, ok = findChannel(reg, "telegram:conf0") + if !ok || ch.Name() != "telegram:conf0" { + t.Fatalf("exact telegram:conf0 not found, got %v/%v", ch, ok) + } +} + +// TestFindChannelBarePlatformResolvesToTokenAccount verifies a bare platform name +// ("kook") that is not an exact adapter name resolves to that platform's first +// registered account (the token-bearing one, since the empty-token bare account +// is never registered). +func TestFindChannelBarePlatformResolvesToTokenAccount(t *testing.T) { + reg := capreg.New() + _ = reg.Register(capreg.Capability{Name: "kook:3fc8", Kind: capreg.KindChannel, Value: testChannel{name: "kook:3fc8"}}) + ch, ok := findChannel(reg, "kook") + if !ok || ch.Name() != "kook:3fc8" { + t.Fatalf("bare kook should resolve to kook:3fc8, got %v/%v", ch, ok) + } +} + +// TestFindChannelUnknownOrBlank rejects a name that matches no channel. +func TestFindChannelUnknownOrBlank(t *testing.T) { + reg := capreg.New() + _ = reg.Register(capreg.Capability{Name: "kook:conf0", Kind: capreg.KindChannel, Value: testChannel{name: "kook:conf0"}}) + if _, ok := findChannel(reg, "unknown"); ok { + t.Fatal("unknown name should not be found") + } + if _, ok := findChannel(reg, ""); ok { + t.Fatal("blank name should not be found") + } + // Skipped: non-channel capabilities must not match. + _ = reg.Register(capreg.Capability{Name: "some-tool", Kind: capreg.KindTool, Value: testChannel{name: "some-tool"}}) + if _, ok := findChannel(reg, "some-tool"); ok { + t.Fatal("non-channel capability must not match findChannel") + } +}