Files
larksuite-cli/internal/core/config_test.go
liangshuo-1 9f81e7e567 feat: add RuntimeContext.BotInfo() for lazy bot identity retrieval (#409)
Add BotInfo() method on RuntimeContext that lazily fetches the current
app's bot open_id and display name from /bot/v3/info on first call,
cached via sync.OnceValues for the lifetime of the process.

- BotInfo struct (OpenID, AppName) in Identity section of runner.go
- fetchBotInfo() uses DoAPIAsBot for consistent header injection
- CanBot() on CliConfig gates the call when bot identity is unavailable
- Nil guard prevents panic in test contexts
- Full test coverage via httpmock.Registry + mounted shortcuts

Change-Id: I40ac710fb52d13939853f71827a5cbdbddd4f80f
2026-04-11 11:53:02 +08:00

211 lines
5.3 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/internal/keychain"
)
// stubKeychain is a minimal KeychainAccess that always returns ErrNotFound.
type stubKeychain struct{}
func (stubKeychain) Get(service, account string) (string, error) {
return "", keychain.ErrNotFound
}
func (stubKeychain) Set(service, account, value string) error { return nil }
func (stubKeychain) Remove(service, account string) error { return nil }
func TestAppConfig_LangSerialization(t *testing.T) {
app := AppConfig{
AppId: "cli_test", AppSecret: PlainSecret("secret"),
Brand: BrandFeishu, Lang: "en", Users: []AppUser{},
}
data, err := json.Marshal(app)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got AppConfig
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Lang != "en" {
t.Errorf("Lang = %q, want %q", got.Lang, "en")
}
}
func TestAppConfig_LangOmitEmpty(t *testing.T) {
app := AppConfig{
AppId: "cli_test", AppSecret: PlainSecret("secret"),
Brand: BrandFeishu, Users: []AppUser{},
}
data, err := json.Marshal(app)
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Lang should be omitted when empty
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("unmarshal raw: %v", err)
}
if _, exists := raw["lang"]; exists {
t.Error("expected lang to be omitted when empty")
}
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
config := &MultiAppConfig{
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
}},
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got MultiAppConfig
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(got.Apps) != 1 {
t.Fatalf("expected 1 app, got %d", len(got.Apps))
}
if got.Apps[0].Lang != "zh" {
t.Errorf("Lang = %q, want %q", got.Apps[0].Lang, "zh")
}
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_new_app",
AppSecret: SecretInput{Ref: &SecretRef{
Source: "keychain",
ID: "appsecret:cli_old_app",
}},
Brand: BrandFeishu,
},
},
}
_, err := ResolveConfigFromMulti(raw, nil, "")
if err == nil {
t.Fatal("expected error for mismatched appId and appSecret keychain key")
}
var cfgErr *ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected ConfigError, got %T: %v", err, err)
}
if cfgErr.Hint == "" {
t.Error("expected non-empty hint in ConfigError")
}
}
func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: PlainSecret("my-secret"),
Brand: BrandFeishu,
},
},
}
cfg, err := ResolveConfigFromMulti(raw, nil, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.AppID != "cli_abc" {
t.Errorf("AppID = %q, want %q", cfg.AppID, "cli_abc")
}
}
func TestResolveConfigFromMulti_MatchingKeychainRefPassesValidation(t *testing.T) {
// Keychain ref matches appId, so validation passes.
// The subsequent ResolveSecretInput will fail (no real keychain),
// but that proves the mismatch check itself passed.
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: SecretInput{Ref: &SecretRef{
Source: "keychain",
ID: "appsecret:cli_abc",
}},
Brand: BrandFeishu,
},
},
}
_, err := ResolveConfigFromMulti(raw, stubKeychain{}, "")
if err == nil {
// stubKeychain returns ErrNotFound, so we expect a keychain error,
// but NOT a mismatch error — that's the point of this test.
t.Fatal("expected error (keychain entry not found), got nil")
}
// The error should come from keychain resolution, NOT from our mismatch check.
var cfgErr *ConfigError
if errors.As(err, &cfgErr) {
if cfgErr.Message == "appId and appSecret keychain key are out of sync" {
t.Fatal("error came from mismatch check, but keys should match")
}
}
}
func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_PROFILE", "missing")
raw := &MultiAppConfig{
CurrentApp: "active",
Apps: []AppConfig{
{
Name: "active",
AppId: "cli_active",
AppSecret: PlainSecret("secret"),
Brand: BrandFeishu,
},
},
}
cfg, err := ResolveConfigFromMulti(raw, nil, "")
if err != nil {
t.Fatalf("ResolveConfigFromMulti() error = %v", err)
}
if cfg.ProfileName != "active" {
t.Fatalf("ResolveConfigFromMulti() profile = %q, want %q", cfg.ProfileName, "active")
}
}
func TestCliConfig_CanBot(t *testing.T) {
tests := []struct {
name string
supportedIdentities uint8
want bool
}{
{"unset (0) defaults to true", 0, true},
{"user only", 1, false},
{"bot only", 2, true},
{"both", 3, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &CliConfig{SupportedIdentities: tt.supportedIdentities}
if got := cfg.CanBot(); got != tt.want {
t.Errorf("CanBot() = %v, want %v", got, tt.want)
}
})
}
}