mcp-framework/secretstore/store_test.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
}