diff --git a/pkg/cmd/application/list/list.go b/pkg/cmd/application/list/list.go index 3473e877..30cd6978 100644 --- a/pkg/cmd/application/list/list.go +++ b/pkg/cmd/application/list/list.go @@ -104,20 +104,14 @@ func runListCmd(opts *ListOptions) error { return nil } - configuredProfiles := opts.Config.ConfiguredProfiles() - configuredAppIDs := make(map[string]string) - for _, p := range configuredProfiles { - configuredAppIDs[p.ApplicationID] = p.Name - } - fmt.Fprintf(opts.IO.Out, "\nYour applications:\n\n") unconfigured := make([]dashboard.Application, 0) + profileApps := apputil.ProfileApplicationIDs(opts.Config.ConfiguredProfiles()) for _, app := range apps { - profileName, configured := configuredAppIDs[app.ID] label := fmt.Sprintf(" %s %s", app.ID, app.Name) - if configured { - fmt.Fprintf(opts.IO.Out, "%s %s\n", label, cs.Greenf("(configured: %s)", profileName)) + if apputil.ApplicationConfigured(opts.Config, profileApps, app.ID) { + fmt.Fprintf(opts.IO.Out, "%s %s\n", label, cs.Green("(configured)")) } else { fmt.Fprintf(opts.IO.Out, "%s %s\n", label, cs.Gray("(not configured)")) unconfigured = append(unconfigured, app) diff --git a/pkg/cmd/application/selectapp/select.go b/pkg/cmd/application/selectapp/select.go index fa143286..eac0389c 100644 --- a/pkg/cmd/application/selectapp/select.go +++ b/pkg/cmd/application/selectapp/select.go @@ -152,21 +152,15 @@ func pickApplication( return nil, fmt.Errorf("--app-name is required in non-interactive mode") } - configuredProfiles := opts.Config.ConfiguredProfiles() - configuredAppIDs := make(map[string]string) - for _, p := range configuredProfiles { - configuredAppIDs[p.ApplicationID] = p.Name - } - cs := opts.IO.ColorScheme() + profileApps := apputil.ProfileApplicationIDs(opts.Config.ConfiguredProfiles()) appOptions := make([]string, len(apps)) for i, app := range apps { label := fmt.Sprintf("%s (%s)", app.ID, app.Name) - if profileName, ok := configuredAppIDs[app.ID]; ok { - appOptions[i] = fmt.Sprintf("%s %s", label, cs.Greenf("profile: %s", profileName)) - } else { - appOptions[i] = label + if apputil.ApplicationConfigured(opts.Config, profileApps, app.ID) { + label = fmt.Sprintf("%s %s", label, cs.Green("(configured)")) } + appOptions[i] = label } var selected int diff --git a/pkg/cmd/describe/describe.go b/pkg/cmd/describe/describe.go index 974f609d..fab8600a 100644 --- a/pkg/cmd/describe/describe.go +++ b/pkg/cmd/describe/describe.go @@ -38,6 +38,11 @@ func NewDescribeCmd(f *cmdutil.Factory) *cobra.Command { Aliases: []string{"schema"}, Args: cobra.ArbitraryArgs, Short: "Describe commands and flags as JSON.", + // Describe only walks the command tree; it needs no credentials and + // must work on a machine with nothing configured. + Annotations: map[string]string{ + "skipAuthCheck": "true", + }, Long: heredoc.Doc(` Describe the CLI's command tree in a machine-readable format. With no arguments, this command describes the root command. diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 069e7f28..7d2626a1 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -136,6 +136,14 @@ func Execute() exitCode { cmdFactory := factory.New(version.Version, &cfg) stderr := cmdFactory.IOStreams.ErrOut + // One-time config.toml → state.toml + keychain migration (GROUT-363). Must + // run before credential resolution, which caches state.toml per command. + if cfg.ShouldMigrate() { + if err := cfg.Migrate(); err != nil && hasDebug { + fmt.Fprintf(stderr, "config migration failed (will retry on next run): %s\n", err) + } + } + // Set up the update notifier. updateMessageChan := make(chan *update.ReleaseInfo) go func() { diff --git a/pkg/cmd/shared/apputil/configured.go b/pkg/cmd/shared/apputil/configured.go new file mode 100644 index 00000000..dc6b14c1 --- /dev/null +++ b/pkg/cmd/shared/apputil/configured.go @@ -0,0 +1,24 @@ +package apputil + +import "github.com/algolia/cli/pkg/config" + +// ProfileApplicationIDs returns the set of application IDs backed by a legacy +// config.toml profile. Built once by the caller so a per-application loop tests +// membership in O(1) instead of re-parsing config.toml on every iteration. +func ProfileApplicationIDs(profiles []*config.Profile) map[string]bool { + ids := make(map[string]bool, len(profiles)) + for _, p := range profiles { + if p.ApplicationID != "" { + ids[p.ApplicationID] = true + } + } + return ids +} + +// ApplicationConfigured reports whether an application is already known to the +// CLI. state.toml is the source of truth (an O(1) cached lookup); profileApps +// is the legacy config.toml fallback while config.toml is still supported +// (remove once it's gone). +func ApplicationConfigured(cfg config.IConfig, profileApps map[string]bool, appID string) bool { + return cfg.ApplicationInState(appID) || profileApps[appID] +} diff --git a/pkg/cmd/shared/apputil/configured_test.go b/pkg/cmd/shared/apputil/configured_test.go new file mode 100644 index 00000000..e468d98e --- /dev/null +++ b/pkg/cmd/shared/apputil/configured_test.go @@ -0,0 +1,46 @@ +package apputil + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/test" +) + +func TestApplicationConfigured(t *testing.T) { + cfg := test.NewDefaultConfigStub() + cfg.SavedApps = map[string]test.SavedApplication{ + "STATE_APP": {Alias: "prod"}, + } + // "default" is the config.toml profile's application ID (legacy fallback). + profileApps := ProfileApplicationIDs(cfg.ConfiguredProfiles()) + + t.Run("in state.toml", func(t *testing.T) { + assert.True(t, ApplicationConfigured(cfg, profileApps, "STATE_APP")) + }) + + t.Run("only in legacy config.toml", func(t *testing.T) { + assert.True(t, ApplicationConfigured(cfg, profileApps, "default")) + }) + + t.Run("unknown application", func(t *testing.T) { + assert.False(t, ApplicationConfigured(cfg, profileApps, "UNKNOWN")) + }) +} + +func TestProfileApplicationIDs(t *testing.T) { + profiles := []*config.Profile{ + {Name: "prod", ApplicationID: "APP1"}, + {Name: "dev", ApplicationID: "APP2"}, + {Name: "broken", ApplicationID: ""}, // skipped: no app ID + } + + ids := ProfileApplicationIDs(profiles) + + assert.True(t, ids["APP1"]) + assert.True(t, ids["APP2"]) + assert.False(t, ids[""]) // empty IDs never become a member + assert.Len(t, ids, 2) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index d09d4c69..097725f5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -32,6 +32,7 @@ type IConfig interface { // New model (state.toml + OS keychain). ActiveApplicationID() string + ApplicationInState(appID string) bool ApplicationIDByAlias(alias string) (string, bool) SaveApplication(appID, alias, apiKeyUUID, apiKey string, setCurrent bool) error SetCrawlerAPIKey(appID, crawlerAPIKey string) error @@ -125,6 +126,14 @@ func (c *Config) loadState() *State { return c.state } +// ApplicationInState reports whether state.toml already holds an entry for the +// given application, i.e. the application has been configured under the new +// storage model. +func (c *Config) ApplicationInState(appID string) bool { + _, ok := c.loadState().Applications[appID] + return ok +} + // StateFileExists reports whether state.toml exists on disk, i.e. the new // storage model (state.toml + OS keychain) is already in use on this machine. func (c *Config) StateFileExists() bool { diff --git a/pkg/config/migrate.go b/pkg/config/migrate.go new file mode 100644 index 00000000..022d8c9a --- /dev/null +++ b/pkg/config/migrate.go @@ -0,0 +1,119 @@ +package config + +import ( + "os" + "sort" + + log "github.com/sirupsen/logrus" + "github.com/spf13/viper" + + "github.com/algolia/cli/pkg/keychain" +) + +// ShouldMigrate reports whether the one-time config.toml → state.toml + +// keychain migration still has to run: config.toml exists and state.toml +// (only written on success, so doubling as the "migrated" marker) does not. +// +// The state.toml check comes first so an already-migrated machine — the +// steady state, hit on every command — settles in a single stat instead of +// also stat-ing config.toml. +func (c *Config) ShouldMigrate() bool { + if c.File == "" || c.StateFileExists() { + return false + } + _, err := os.Stat(c.File) + return err == nil +} + +// Migrate moves the legacy config.toml profiles into the new model (state.toml +// + OS keychain); config.toml is never modified. Keychain first, state.toml +// last (atomic): a failure leaves state.toml absent, so the migration retries +// on the next run. +func (c *Config) Migrate() error { + // An unparseable config.toml must not mark the migration as done: abort + // before writing state.toml so it retries once the file is fixed. + if err := viper.ReadInConfig(); err != nil { + return err + } + + state := &State{Applications: map[string]ApplicationState{}} + + for _, profile := range c.migratableProfiles() { + secrets := keychain.AppSecrets{ + APIKey: profile.APIKey, + CrawlerAPIKey: viper.GetString(profile.GetFieldName("crawler_api_key")), + } + if err := keychain.SaveAppSecrets(profile.ApplicationID, secrets); err != nil { + return err + } + + state.UpsertApplication(profile.ApplicationID, ApplicationState{ + Alias: profile.Name, + SearchHosts: profile.SearchHosts, + CrawlerUserID: viper.GetString(profile.GetFieldName("crawler_user_id")), + }) + if profile.Default { + state.SetCurrentApplication(profile.ApplicationID) + } + } + + return state.Save(c.StateFile) +} + +// migratableProfiles applies the skip rules: profiles without application_id +// or api_key are dropped, admin_api_key never migrates, and the default +// profile wins when several share an application_id. Name order keeps the +// conflict resolution deterministic (ConfiguredProfiles iterates a map). +func (c *Config) migratableProfiles() []*Profile { + // Decode the profiles here rather than through ConfiguredProfiles, whose + // log.Fatalf on an undecodable entry would brick every command at startup. + configs := viper.AllSettings() + profiles := make([]*Profile, 0, len(configs)) + for name := range configs { + profile := &Profile{Name: name} + if err := viper.UnmarshalKey(name, profile); err != nil { + log.Warnf("config migration: skipping profile %q: %s", name, err) + continue + } + profiles = append(profiles, profile) + } + sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name }) + + selected := make([]*Profile, 0, len(profiles)) + owner := map[string]int{} // application ID → index in selected + + for _, profile := range profiles { + if profile.AdminAPIKey != "" { + log.Warnf( + "config migration: profile %q: admin_api_key is not migrated, use ALGOLIA_ADMIN_API_KEY or --api-key instead", + profile.Name, + ) + } + if profile.ApplicationID == "" { + log.Warnf("config migration: skipping profile %q: no application_id", profile.Name) + continue + } + if profile.APIKey == "" { + log.Warnf("config migration: skipping profile %q: empty api_key", profile.Name) + continue + } + if i, ok := owner[profile.ApplicationID]; ok { + kept, dropped := selected[i], profile + if profile.Default && !kept.Default { + selected[i] = profile + kept, dropped = profile, kept + } + log.Warnf( + "config migration: skipping profile %q: application %q already migrated from profile %q", + dropped.Name, + dropped.ApplicationID, + kept.Name, + ) + continue + } + owner[profile.ApplicationID] = len(selected) + selected = append(selected, profile) + } + + return selected +} diff --git a/pkg/config/migrate_test.go b/pkg/config/migrate_test.go new file mode 100644 index 00000000..841ac930 --- /dev/null +++ b/pkg/config/migrate_test.go @@ -0,0 +1,322 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + logtest "github.com/sirupsen/logrus/hooks/test" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" + + "github.com/algolia/cli/pkg/keychain" +) + +func TestConfig_ShouldMigrate(t *testing.T) { + tests := []struct { + name string + configFile bool + stateFile bool + want bool + }{ + { + name: "config.toml only: migration pending", + configFile: true, + stateFile: false, + want: true, + }, + { + name: "both files: already migrated (or new model in use)", + configFile: true, + stateFile: true, + want: false, + }, + { + name: "state.toml only: nothing to migrate", + configFile: false, + stateFile: true, + want: false, + }, + { + name: "no files: fresh install", + configFile: false, + stateFile: false, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + cfg := &Config{ + File: filepath.Join(dir, "config.toml"), + StateFile: filepath.Join(dir, "state.toml"), + } + if tt.configFile { + require.NoError(t, os.WriteFile(cfg.File, []byte(""), 0o600)) + } + if tt.stateFile { + require.NoError(t, os.WriteFile(cfg.StateFile, []byte(""), 0o600)) + } + + assert.Equal(t, tt.want, cfg.ShouldMigrate()) + }) + } +} + +func TestConfig_ShouldMigrate_unresolvedPaths(t *testing.T) { + // InitConfig never ran: paths are empty, the trigger must stay off. + cfg := &Config{} + assert.False(t, cfg.ShouldMigrate()) +} + +// migrationConfig writes a config.toml, points the global viper at it +// (ConfiguredProfiles reads through viper) and returns a Config ready to migrate. +func migrationConfig(t *testing.T, content string) *Config { + t.Helper() + + dir := t.TempDir() + configFile := filepath.Join(dir, "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte(content), 0o600)) + + viper.Reset() + viper.SetConfigType("toml") + viper.SetConfigFile(configFile) + require.NoError(t, viper.ReadInConfig()) + t.Cleanup(viper.Reset) + + return &Config{ + File: configFile, + StateFile: filepath.Join(dir, "state.toml"), + } +} + +func TestConfig_Migrate(t *testing.T) { + keyring.MockInit() + cfg := migrationConfig(t, `[prod] +application_id = "APP1" +api_key = "key-1" +crawler_api_key = "crawler-1" +crawler_user_id = "crawler-user" +search_hosts = ["h1.algolia.net", "h2.algolia.net"] +default = true + +[dev] +application_id = "APP2" +api_key = "key-2" +`) + + require.NoError(t, cfg.Migrate()) + + prod, err := keychain.LoadAppSecrets("APP1") + require.NoError(t, err) + require.NotNil(t, prod) + assert.Equal(t, "key-1", prod.APIKey) + assert.Equal(t, "crawler-1", prod.CrawlerAPIKey) + + dev, err := keychain.LoadAppSecrets("APP2") + require.NoError(t, err) + require.NotNil(t, dev) + assert.Equal(t, "key-2", dev.APIKey) + assert.Empty(t, dev.CrawlerAPIKey) + + st, err := LoadState(cfg.StateFile) + require.NoError(t, err) + assert.Equal(t, "APP1", st.CurrentApplicationID) + assert.Equal(t, "prod", st.Applications["APP1"].Alias) + assert.Equal(t, "dev", st.Applications["APP2"].Alias) + assert.Empty(t, st.Applications["APP1"].APIKeyUUID) // unknown for legacy keys + assert.Equal( + t, + []string{"h1.algolia.net", "h2.algolia.net"}, + st.Applications["APP1"].SearchHosts, + ) + assert.Equal(t, "crawler-user", st.Applications["APP1"].CrawlerUserID) + assert.Empty(t, st.Applications["APP2"].SearchHosts) + assert.Empty(t, st.Applications["APP2"].CrawlerUserID) + + assert.False(t, cfg.ShouldMigrate()) +} + +func TestConfig_Migrate_NoDefaultProfileLeavesCurrentEmpty(t *testing.T) { + keyring.MockInit() + cfg := migrationConfig(t, `[dev] +application_id = "APP2" +api_key = "key-2" +`) + + require.NoError(t, cfg.Migrate()) + + st, err := LoadState(cfg.StateFile) + require.NoError(t, err) + assert.Empty(t, st.CurrentApplicationID) + assert.Equal(t, "dev", st.Applications["APP2"].Alias) +} + +func TestConfig_Migrate_EmptyConfigStillWritesState(t *testing.T) { + keyring.MockInit() + cfg := migrationConfig(t, "") + + require.NoError(t, cfg.Migrate()) + + // An empty state.toml must exist, otherwise the migration re-runs forever. + st, err := LoadState(cfg.StateFile) + require.NoError(t, err) + assert.Empty(t, st.CurrentApplicationID) + assert.Empty(t, st.Applications) + assert.False(t, cfg.ShouldMigrate()) +} + +func TestConfig_Migrate_KeychainFailureLeavesStateAbsent(t *testing.T) { + keyring.MockInitWithError(keyring.ErrUnsupportedPlatform) + cfg := migrationConfig(t, `[prod] +application_id = "APP1" +api_key = "key-1" +`) + + require.Error(t, cfg.Migrate()) + + // state.toml untouched: the migration retries on the next run. + assert.NoFileExists(t, cfg.StateFile) + assert.True(t, cfg.ShouldMigrate()) +} + +func TestConfig_Migrate_SkipRules(t *testing.T) { + keyring.MockInit() + hook := logtest.NewGlobal() + t.Cleanup(hook.Reset) + + cfg := migrationConfig(t, `[nokey] +application_id = "APP3" +api_key = "" + +[noapp] +api_key = "key-x" + +[adminonly] +application_id = "APP4" +admin_api_key = "admin-key" +`) + + require.NoError(t, cfg.Migrate()) + + // Nothing migrated, but the trigger still turns off. + for _, appID := range []string{"APP3", "APP4"} { + secrets, err := keychain.LoadAppSecrets(appID) + require.NoError(t, err) + assert.Nil(t, secrets) + } + st, err := LoadState(cfg.StateFile) + require.NoError(t, err) + assert.Empty(t, st.Applications) + assert.False(t, cfg.ShouldMigrate()) + + logs := make([]string, 0, len(hook.AllEntries())) + for _, entry := range hook.AllEntries() { + logs = append(logs, entry.Message) + } + assert.Contains(t, logs, + `config migration: skipping profile "nokey": empty api_key`) + assert.Contains(t, logs, + `config migration: skipping profile "noapp": no application_id`) + assert.Contains(t, logs, + `config migration: skipping profile "adminonly": empty api_key`) + assert.Contains( + t, + logs, + `config migration: profile "adminonly": admin_api_key is not migrated, use ALGOLIA_ADMIN_API_KEY or --api-key instead`, + ) +} + +func TestConfig_Migrate_DuplicateApplicationKeepsDefault(t *testing.T) { + keyring.MockInit() + cfg := migrationConfig(t, `[backup] +application_id = "APP1" +api_key = "backup-key" + +[prod] +application_id = "APP1" +api_key = "prod-key" +default = true +`) + + require.NoError(t, cfg.Migrate()) + + st, err := LoadState(cfg.StateFile) + require.NoError(t, err) + require.Len(t, st.Applications, 1) + assert.Equal(t, "prod", st.Applications["APP1"].Alias) + assert.Equal(t, "APP1", st.CurrentApplicationID) + + secrets, err := keychain.LoadAppSecrets("APP1") + require.NoError(t, err) + require.NotNil(t, secrets) + assert.Equal(t, "prod-key", secrets.APIKey) +} + +func TestConfig_Migrate_AdminKeyAlongsideAPIKeyStillMigrates(t *testing.T) { + keyring.MockInit() + cfg := migrationConfig(t, `[prod] +application_id = "APP1" +api_key = "key-1" +admin_api_key = "admin-1" +default = true +`) + + require.NoError(t, cfg.Migrate()) + + secrets, err := keychain.LoadAppSecrets("APP1") + require.NoError(t, err) + require.NotNil(t, secrets) + assert.Equal(t, "key-1", secrets.APIKey) + + st, err := LoadState(cfg.StateFile) + require.NoError(t, err) + assert.Equal(t, "prod", st.Applications["APP1"].Alias) +} + +func TestConfig_Migrate_UndecodableProfileSkipped(t *testing.T) { + keyring.MockInit() + cfg := migrationConfig(t, `telemetry = "off" + +[prod] +application_id = "APP1" +api_key = "key-1" +default = true + +[bad] +application_id = "APP2" +api_key = ["a", "b"] +`) + + // Undecodable entries (root scalar, wrong types) are skipped, not fatal. + require.NoError(t, cfg.Migrate()) + + st, err := LoadState(cfg.StateFile) + require.NoError(t, err) + require.Len(t, st.Applications, 1) + assert.Equal(t, "APP1", st.CurrentApplicationID) + assert.Equal(t, "prod", st.Applications["APP1"].Alias) +} + +func TestConfig_Migrate_UnreadableConfigAborts(t *testing.T) { + keyring.MockInit() + dir := t.TempDir() + configFile := filepath.Join(dir, "config.toml") + require.NoError(t, os.WriteFile(configFile, []byte("not [ valid ### toml\n"), 0o600)) + + viper.Reset() + viper.SetConfigType("toml") + viper.SetConfigFile(configFile) + _ = viper.ReadInConfig() // swallowed, like InitConfig does + t.Cleanup(viper.Reset) + + cfg := &Config{File: configFile, StateFile: filepath.Join(dir, "state.toml")} + + // No state.toml written: the migration retries once the file is fixed. + require.Error(t, cfg.Migrate()) + assert.NoFileExists(t, cfg.StateFile) + assert.True(t, cfg.ShouldMigrate()) +} diff --git a/pkg/config/profile.go b/pkg/config/profile.go index 7cd1c3bc..ba606716 100644 --- a/pkg/config/profile.go +++ b/pkg/config/profile.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "path/filepath" "strings" @@ -94,7 +95,12 @@ func (p *Profile) GetAPIKey() (string, error) { if secrets := p.config.appSecretsFor(appID); secrets != nil && secrets.APIKey != "" { return secrets.APIKey, nil } - return "", ErrAPIKeyNotConfigured + // The application is set but its key isn't in this machine's + // keychain (e.g. state.toml synced across machines without it). + return "", fmt.Errorf( + "no API key stored in your keychain for the current application %q; run `algolia application select` to store one, or set ALGOLIA_API_KEY", + appID, + ) } } @@ -146,6 +152,16 @@ func (p *Profile) GetSearchHosts() []string { return p.SearchHosts } + // New model: hosts recorded for the resolved application. Empty falls + // through to the legacy config.toml lookup while both models coexist. + if p.config != nil { + if appID := p.config.activeApplicationID(); appID != "" { + if hosts := p.config.loadState().Applications[appID].SearchHosts; len(hosts) > 0 { + return hosts + } + } + } + if p.Name == "" { p.LoadDefault() } @@ -170,6 +186,16 @@ func (p *Profile) GetCrawlerUserID() (string, error) { return os.Getenv("ALGOLIA_CRAWLER_USER_ID"), nil } + // New model: the user ID recorded for the resolved application. Empty + // falls through to the legacy config.toml lookup. + if p.config != nil { + if appID := p.config.activeApplicationID(); appID != "" { + if userID := p.config.loadState().Applications[appID].CrawlerUserID; userID != "" { + return userID, nil + } + } + } + if p.Name == "" { p.LoadDefault() } @@ -198,7 +224,12 @@ func (p *Profile) GetCrawlerAPIKey() (string, error) { secrets.CrawlerAPIKey != "" { return secrets.CrawlerAPIKey, nil } - return "", ErrCrawlerAPIKeyNotConfigured + // The application is set but its crawler key isn't in this + // machine's keychain. + return "", fmt.Errorf( + "no Crawler API key stored in your keychain for the current application %q; run `algolia auth crawler` to store one, or set ALGOLIA_CRAWLER_API_KEY", + appID, + ) } } diff --git a/pkg/config/resolution_test.go b/pkg/config/resolution_test.go index 00986fd8..0dd32d51 100644 --- a/pkg/config/resolution_test.go +++ b/pkg/config/resolution_test.go @@ -242,4 +242,74 @@ func TestProfile_GetAPIKey_ActiveAppWithoutKeyErrors(t *testing.T) { // APP1 resolved from state but no keychain key → error, never "legacy-key". _, err := cfg.Profile().GetAPIKey() require.Error(t, err) + // The message names the application and points to a fix, rather than the + // generic "not configured yet". + assert.Contains(t, err.Error(), "APP1") + assert.Contains(t, err.Error(), "application select") +} + +func TestProfile_GetSearchHosts_FromState(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.toml") + require.NoError(t, os.WriteFile(path, []byte( + "current_application_id = \"APP1\"\n\n[applications.APP1]\nalias = \"prod\"\nsearch_hosts = [\"h1\", \"h2\"]\n", + ), 0o600)) + + cfg := &Config{StateFile: path} + cfg.CurrentProfile.config = cfg + + assert.Equal(t, []string{"h1", "h2"}, cfg.Profile().GetSearchHosts()) +} + +func TestProfile_GetSearchHosts_StateEmptyFallsBackToConfigToml(t *testing.T) { + statePath := filepath.Join(t.TempDir(), "state.toml") + require.NoError( + t, + os.WriteFile(statePath, []byte("current_application_id = \"APP1\"\n"), 0o600), + ) + + configFile := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile( + configFile, + []byte( + "[legacy]\napplication_id = \"APP1\"\nsearch_hosts = [\"legacy-host\"]\ndefault = true\n", + ), + 0o600, + )) + viper.Reset() + viper.SetConfigType("toml") + viper.SetConfigFile(configFile) + require.NoError(t, viper.ReadInConfig()) + t.Cleanup(viper.Reset) + + cfg := &Config{StateFile: statePath} + cfg.CurrentProfile.config = cfg + + // No hosts in state.toml for APP1: the legacy lookup still answers while + // config.toml exists. + assert.Equal(t, []string{"legacy-host"}, cfg.Profile().GetSearchHosts()) +} + +func TestProfile_GetCrawlerUserID_FromState(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.toml") + require.NoError(t, os.WriteFile(path, []byte( + "current_application_id = \"APP1\"\n\n[applications.APP1]\nalias = \"prod\"\ncrawler_user_id = \"crawler-user\"\n", + ), 0o600)) + + cfg := &Config{StateFile: path} + cfg.CurrentProfile.config = cfg + + userID, err := cfg.Profile().GetCrawlerUserID() + require.NoError(t, err) + assert.Equal(t, "crawler-user", userID) +} + +func TestConfig_ApplicationInState(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.toml") + require.NoError(t, os.WriteFile(path, []byte( + "current_application_id = \"APP1\"\n\n[applications.APP1]\nalias = \"prod\"\n", + ), 0o600)) + + cfg := &Config{StateFile: path} + assert.True(t, cfg.ApplicationInState("APP1")) + assert.False(t, cfg.ApplicationInState("APP2")) } diff --git a/pkg/config/state.go b/pkg/config/state.go index 99d4809d..3b4c059f 100644 --- a/pkg/config/state.go +++ b/pkg/config/state.go @@ -11,8 +11,10 @@ import ( // ApplicationState holds the non-secret, per-application data persisted in // state.toml. Secrets (API keys) live in the OS keychain, not here. type ApplicationState struct { - APIKeyUUID string `toml:"api_key_uuid"` - Alias string `toml:"alias"` + APIKeyUUID string `toml:"api_key_uuid,omitempty"` + Alias string `toml:"alias"` + SearchHosts []string `toml:"search_hosts,omitempty"` + CrawlerUserID string `toml:"crawler_user_id,omitempty"` } // State is the in-memory representation of state.toml, the new source of truth diff --git a/test/config.go b/test/config.go index 0b0828ef..65a77ada 100644 --- a/test/config.go +++ b/test/config.go @@ -147,6 +147,11 @@ func (c *ConfigStub) ActiveApplicationID() string { return c.ActiveAppID } +func (c *ConfigStub) ApplicationInState(appID string) bool { + _, ok := c.SavedApps[appID] + return ok +} + func (c *ConfigStub) ApplicationIDByAlias(alias string) (string, bool) { for appID, app := range c.SavedApps { if app.Alias == alias { @@ -156,7 +161,10 @@ func (c *ConfigStub) ApplicationIDByAlias(alias string) (string, bool) { return "", false } -func (c *ConfigStub) SaveApplication(appID, alias, apiKeyUUID, apiKey string, setCurrent bool) error { +func (c *ConfigStub) SaveApplication( + appID, alias, apiKeyUUID, apiKey string, + setCurrent bool, +) error { if c.SavedApps == nil { c.SavedApps = map[string]SavedApplication{} }