335 lines
8 KiB
Go
335 lines
8 KiB
Go
package secretstore
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/99designs/keyring"
|
|
)
|
|
|
|
type stubKeyring struct {
|
|
items map[string]keyring.Item
|
|
}
|
|
|
|
func (s *stubKeyring) Get(key string) (keyring.Item, error) {
|
|
item, ok := s.items[key]
|
|
if !ok {
|
|
return keyring.Item{}, keyring.ErrKeyNotFound
|
|
}
|
|
|
|
return item, nil
|
|
}
|
|
|
|
func (s *stubKeyring) GetMetadata(key string) (keyring.Metadata, error) {
|
|
item, err := s.Get(key)
|
|
if err != nil {
|
|
return keyring.Metadata{}, err
|
|
}
|
|
|
|
return keyring.Metadata{Item: &item}, nil
|
|
}
|
|
|
|
func (s *stubKeyring) Set(item keyring.Item) error {
|
|
if s.items == nil {
|
|
s.items = map[string]keyring.Item{}
|
|
}
|
|
|
|
s.items[item.Key] = item
|
|
return nil
|
|
}
|
|
|
|
func (s *stubKeyring) Remove(key string) error {
|
|
delete(s.items, key)
|
|
return nil
|
|
}
|
|
|
|
func (s *stubKeyring) Keys() ([]string, error) {
|
|
keys := make([]string, 0, len(s.items))
|
|
for key := range s.items {
|
|
keys = append(keys, key)
|
|
}
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
func withKeyringHooks(
|
|
t *testing.T,
|
|
available []keyring.BackendType,
|
|
opener func(cfg keyring.Config) (keyring.Keyring, error),
|
|
) {
|
|
t.Helper()
|
|
|
|
prevAvailable := availableKeyringPolicy
|
|
prevOpen := openKeyring
|
|
availableKeyringPolicy = func() []keyring.BackendType {
|
|
return available
|
|
}
|
|
openKeyring = opener
|
|
|
|
t.Cleanup(func() {
|
|
availableKeyringPolicy = prevAvailable
|
|
openKeyring = prevOpen
|
|
})
|
|
}
|
|
|
|
func TestOpenRejectsInvalidPolicy(t *testing.T) {
|
|
_, err := Open(Options{
|
|
ServiceName: "mcp-framework-test",
|
|
BackendPolicy: BackendPolicy("invalid"),
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !errors.Is(err, ErrInvalidBackendPolicy) {
|
|
t.Fatalf("error = %v, want ErrInvalidBackendPolicy", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenAutoUsesAvailableKeyringBackends(t *testing.T) {
|
|
var gotAllowed []keyring.BackendType
|
|
ring := &stubKeyring{}
|
|
|
|
withKeyringHooks(t, []keyring.BackendType{
|
|
keyring.SecretServiceBackend,
|
|
keyring.KWalletBackend,
|
|
}, func(cfg keyring.Config) (keyring.Keyring, error) {
|
|
gotAllowed = append([]keyring.BackendType(nil), cfg.AllowedBackends...)
|
|
return ring, nil
|
|
})
|
|
|
|
store, err := Open(Options{
|
|
ServiceName: "mcp-framework-test",
|
|
BackendPolicy: BackendAuto,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Open returned error: %v", err)
|
|
}
|
|
|
|
if err := store.SetSecret("token", "API token", "secret-value"); err != nil {
|
|
t.Fatalf("SetSecret returned error: %v", err)
|
|
}
|
|
|
|
value, err := store.GetSecret("token")
|
|
if err != nil {
|
|
t.Fatalf("GetSecret returned error: %v", err)
|
|
}
|
|
if value != "secret-value" {
|
|
t.Fatalf("GetSecret = %q, want secret-value", value)
|
|
}
|
|
|
|
if len(gotAllowed) != 2 {
|
|
t.Fatalf("allowed backends = %v, want two entries", gotAllowed)
|
|
}
|
|
}
|
|
|
|
func TestOpenAutoFallsBackToEnvironmentWhenNoKeyringBackendIsAvailable(t *testing.T) {
|
|
withKeyringHooks(t, nil, func(cfg keyring.Config) (keyring.Keyring, error) {
|
|
t.Fatal("unexpected keyring open call")
|
|
return nil, nil
|
|
})
|
|
|
|
store, err := Open(Options{
|
|
ServiceName: "mcp-framework-test",
|
|
BackendPolicy: BackendAuto,
|
|
LookupEnv: func(name string) (string, bool) {
|
|
if name == "EMAIL_CREDENTIALS" {
|
|
return `{"username":"alice"}`, true
|
|
}
|
|
return "", false
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Open returned error: %v", err)
|
|
}
|
|
|
|
value, err := store.GetSecret("EMAIL_CREDENTIALS")
|
|
if err != nil {
|
|
t.Fatalf("GetSecret returned error: %v", err)
|
|
}
|
|
if value != `{"username":"alice"}` {
|
|
t.Fatalf("GetSecret = %q", value)
|
|
}
|
|
}
|
|
|
|
func TestOpenKeyringAnyReturnsExplicitUnavailableError(t *testing.T) {
|
|
withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) {
|
|
t.Fatal("unexpected keyring open call")
|
|
return nil, nil
|
|
})
|
|
|
|
_, err := Open(Options{
|
|
ServiceName: "mcp-framework-test",
|
|
BackendPolicy: BackendKWalletOnly,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
|
|
var backendErr *BackendUnavailableError
|
|
if !errors.As(err, &backendErr) {
|
|
t.Fatalf("error = %v, want BackendUnavailableError", err)
|
|
}
|
|
if !errors.Is(err, ErrBackendUnavailable) {
|
|
t.Fatalf("error = %v, want ErrBackendUnavailable", err)
|
|
}
|
|
if backendErr.Required != "kwallet" {
|
|
t.Fatalf("required backend = %q, want kwallet", backendErr.Required)
|
|
}
|
|
}
|
|
|
|
func TestOpenEnvOnlyIsReadOnlyAndUsesLookupEnv(t *testing.T) {
|
|
store, err := Open(Options{
|
|
BackendPolicy: BackendEnvOnly,
|
|
LookupEnv: func(name string) (string, bool) {
|
|
if name == "API_TOKEN" {
|
|
return "from-env", true
|
|
}
|
|
return "", false
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Open returned error: %v", err)
|
|
}
|
|
|
|
value, err := store.GetSecret("API_TOKEN")
|
|
if err != nil {
|
|
t.Fatalf("GetSecret returned error: %v", err)
|
|
}
|
|
if value != "from-env" {
|
|
t.Fatalf("GetSecret = %q, want from-env", value)
|
|
}
|
|
|
|
err = store.SetSecret("API_TOKEN", "API token", "new-value")
|
|
if !errors.Is(err, ErrReadOnly) {
|
|
t.Fatalf("SetSecret error = %v, want ErrReadOnly", err)
|
|
}
|
|
|
|
err = store.DeleteSecret("API_TOKEN")
|
|
if !errors.Is(err, ErrReadOnly) {
|
|
t.Fatalf("DeleteSecret error = %v, want ErrReadOnly", err)
|
|
}
|
|
}
|
|
|
|
func TestJSONHelpersRoundTrip(t *testing.T) {
|
|
ring := &stubKeyring{}
|
|
withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) {
|
|
return ring, nil
|
|
})
|
|
|
|
store, err := Open(Options{
|
|
ServiceName: "mcp-framework-test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Open returned error: %v", err)
|
|
}
|
|
|
|
type credentials struct {
|
|
Host string `json:"host"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
input := credentials{
|
|
Host: "imap.example.com",
|
|
Username: "alice",
|
|
Password: "s3cr3t",
|
|
}
|
|
|
|
if err := SetJSON(store, "imap-credentials", "IMAP credentials", input); err != nil {
|
|
t.Fatalf("SetJSON returned error: %v", err)
|
|
}
|
|
|
|
output, err := GetJSON[credentials](store, "imap-credentials")
|
|
if err != nil {
|
|
t.Fatalf("GetJSON returned error: %v", err)
|
|
}
|
|
|
|
if output != input {
|
|
t.Fatalf("GetJSON = %#v, want %#v", output, input)
|
|
}
|
|
}
|
|
|
|
func TestEffectiveBackendPolicyReportsConcreteBackend(t *testing.T) {
|
|
t.Run("env-only", func(t *testing.T) {
|
|
store, err := Open(Options{
|
|
BackendPolicy: BackendEnvOnly,
|
|
LookupEnv: func(string) (string, bool) { return "", false },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Open returned error: %v", err)
|
|
}
|
|
|
|
if got := EffectiveBackendPolicy(store); got != BackendEnvOnly {
|
|
t.Fatalf("EffectiveBackendPolicy = %q, want %q", got, BackendEnvOnly)
|
|
}
|
|
})
|
|
|
|
t.Run("keyring", func(t *testing.T) {
|
|
withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) {
|
|
return &stubKeyring{}, nil
|
|
})
|
|
|
|
store, err := Open(Options{
|
|
ServiceName: "mcp-framework-test",
|
|
BackendPolicy: BackendAuto,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Open returned error: %v", err)
|
|
}
|
|
|
|
if got := EffectiveBackendPolicy(store); got != BackendKeyringAny {
|
|
t.Fatalf("EffectiveBackendPolicy = %q, want %q", got, BackendKeyringAny)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSetSecretVerifiedWritesThenReadsBack(t *testing.T) {
|
|
ring := &stubKeyring{}
|
|
withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) {
|
|
return ring, nil
|
|
})
|
|
|
|
store, err := Open(Options{
|
|
ServiceName: "mcp-framework-test",
|
|
BackendPolicy: BackendAuto,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Open returned error: %v", err)
|
|
}
|
|
|
|
if err := SetSecretVerified(store, "token", "API token", "secret-value"); err != nil {
|
|
t.Fatalf("SetSecretVerified returned error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetSecretVerifiedFailsOnReadBackMismatch(t *testing.T) {
|
|
store := &mismatchSecretStore{}
|
|
|
|
err := SetSecretVerified(store, "token", "API token", "secret-value")
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("error = %v, want wrapped ErrNotFound", err)
|
|
}
|
|
if store.setCalls != 1 {
|
|
t.Fatalf("setCalls = %d, want 1", store.setCalls)
|
|
}
|
|
}
|
|
|
|
type mismatchSecretStore struct {
|
|
setCalls int
|
|
}
|
|
|
|
func (s *mismatchSecretStore) SetSecret(name, label, secret string) error {
|
|
s.setCalls++
|
|
return nil
|
|
}
|
|
|
|
func (s *mismatchSecretStore) GetSecret(name string) (string, error) {
|
|
return "", ErrNotFound
|
|
}
|
|
|
|
func (s *mismatchSecretStore) DeleteSecret(name string) error {
|
|
return nil
|
|
}
|