Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions pkg/keychain/app_secrets.go
Original file line number Diff line number Diff line change
@@ -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
}
81 changes: 81 additions & 0 deletions pkg/keychain/app_secrets_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading