diff --git a/pkg/keychain/app_secrets.go b/pkg/keychain/app_secrets.go new file mode 100644 index 00000000..18d81491 --- /dev/null +++ b/pkg/keychain/app_secrets.go @@ -0,0 +1,69 @@ +package keychain + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/zalando/go-keyring" +) + +const ( + // service is the OS keychain service the CLI stores secrets under. It is + // shared with the OAuth token entry (pkg/auth); per-application secrets are + // namespaced by their user key so they never collide. + service = "algolia-cli" + + // appSecretsUserPrefix namespaces per-application keychain entries. + appSecretsUserPrefix = "app:" +) + +// AppSecrets holds the secret credentials for a single application, persisted +// as a JSON blob in the OS keychain. +type AppSecrets struct { + APIKey string `json:"api_key"` + CrawlerAPIKey string `json:"crawler_api_key,omitempty"` +} + +// appSecretsUser returns the keychain user key for a given application ID. +func appSecretsUser(appID string) string { + return appSecretsUserPrefix + appID +} + +// SaveAppSecrets persists the secrets for an application to the OS keychain. +func SaveAppSecrets(appID string, secrets AppSecrets) error { + if appID == "" { + return fmt.Errorf("appID is required") + } + + data, err := json.Marshal(secrets) + if err != nil { + return err + } + + return keyring.Set(service, appSecretsUser(appID), string(data)) +} + +// LoadAppSecrets reads an application's secrets from the OS keychain. A missing +// entry is not an error: it returns (nil, nil). Real failures (keychain +// unavailable, malformed data) return an error. +func LoadAppSecrets(appID string) (*AppSecrets, error) { + if appID == "" { + return nil, fmt.Errorf("appID is required") + } + + secret, err := keyring.Get(service, appSecretsUser(appID)) + if errors.Is(err, keyring.ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + + var secrets AppSecrets + if err := json.Unmarshal([]byte(secret), &secrets); err != nil { + return nil, err + } + + return &secrets, nil +} diff --git a/pkg/keychain/app_secrets_test.go b/pkg/keychain/app_secrets_test.go new file mode 100644 index 00000000..b938694e --- /dev/null +++ b/pkg/keychain/app_secrets_test.go @@ -0,0 +1,81 @@ +package keychain + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" +) + +func TestAppSecrets_SaveAndLoadRoundTrip(t *testing.T) { + keyring.MockInit() + + require.NoError(t, SaveAppSecrets("APP1", AppSecrets{ + APIKey: "key-1", + CrawlerAPIKey: "crawler-1", + })) + + loaded, err := LoadAppSecrets("APP1") + require.NoError(t, err) + require.NotNil(t, loaded) + assert.Equal(t, "key-1", loaded.APIKey) + assert.Equal(t, "crawler-1", loaded.CrawlerAPIKey) +} + +func TestAppSecrets_LoadMissingReturnsNil(t *testing.T) { + keyring.MockInit() + + loaded, err := LoadAppSecrets("UNKNOWN") + require.NoError(t, err) + assert.Nil(t, loaded) +} + +func TestAppSecrets_PerAppIsolationAndOptionalCrawlerKey(t *testing.T) { + keyring.MockInit() + + require.NoError(t, SaveAppSecrets("APP1", AppSecrets{APIKey: "key-1"})) + require.NoError( + t, + SaveAppSecrets("APP2", AppSecrets{APIKey: "key-2", CrawlerAPIKey: "crawler-2"}), + ) + + app1, err := LoadAppSecrets("APP1") + require.NoError(t, err) + require.NotNil(t, app1) + assert.Equal(t, "key-1", app1.APIKey) + assert.Empty(t, app1.CrawlerAPIKey) // never set → stays empty + + app2, err := LoadAppSecrets("APP2") + require.NoError(t, err) + require.NotNil(t, app2) + assert.Equal(t, "key-2", app2.APIKey) + assert.Equal(t, "crawler-2", app2.CrawlerAPIKey) +} + +func TestAppSecrets_EmptyAppIDIsRejected(t *testing.T) { + keyring.MockInit() + + require.Error(t, SaveAppSecrets("", AppSecrets{APIKey: "key-1"})) + + _, err := LoadAppSecrets("") + require.Error(t, err) +} + +func TestAppSecrets_LoadKeychainErrorPropagates(t *testing.T) { + keyring.MockInitWithError(errors.New("keychain unavailable")) + + loaded, err := LoadAppSecrets("APP1") + require.Error(t, err) + assert.Nil(t, loaded) +} + +func TestAppSecrets_LoadMalformedJSONReturnsError(t *testing.T) { + keyring.MockInit() + require.NoError(t, keyring.Set(service, appSecretsUser("BAD"), "not-json")) + + loaded, err := LoadAppSecrets("BAD") + require.Error(t, err) + assert.Nil(t, loaded) +}