feat: support structured secrets and backend policies
This commit is contained in:
parent
ad463d971e
commit
246e63ec2b
3 changed files with 474 additions and 11 deletions
48
README.md
48
README.md
|
|
@ -115,7 +115,14 @@ Notes :
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
Le package `secretstore` s'appuie sur le wallet natif du système :
|
Le package `secretstore` supporte plusieurs politiques de backend :
|
||||||
|
|
||||||
|
- `auto` : comportement par défaut, utilise un backend keyring disponible et peut retomber sur l'environnement si `LookupEnv` est fourni
|
||||||
|
- `kwallet-only` : impose KWallet et retourne une erreur explicite si KWallet n'est pas disponible
|
||||||
|
- `keyring-any` : impose l'utilisation d'un backend keyring disponible
|
||||||
|
- `env-only` : lecture seule depuis les variables d'environnement
|
||||||
|
|
||||||
|
Backends keyring typiques :
|
||||||
|
|
||||||
- macOS : Keychain
|
- macOS : Keychain
|
||||||
- Linux : Secret Service ou KWallet selon l'environnement
|
- Linux : Secret Service ou KWallet selon l'environnement
|
||||||
|
|
@ -125,7 +132,8 @@ Exemple :
|
||||||
|
|
||||||
```go
|
```go
|
||||||
store, err := secretstore.Open(secretstore.Options{
|
store, err := secretstore.Open(secretstore.Options{
|
||||||
ServiceName: "my-mcp",
|
ServiceName: "my-mcp",
|
||||||
|
BackendPolicy: secretstore.BackendAuto,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -146,6 +154,42 @@ default:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Pour imposer KWallet sur Linux :
|
||||||
|
|
||||||
|
```go
|
||||||
|
store, err := secretstore.Open(secretstore.Options{
|
||||||
|
ServiceName: "email-mcp",
|
||||||
|
BackendPolicy: secretstore.BackendKWalletOnly,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour stocker un secret structuré en JSON :
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Credentials struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = secretstore.SetJSON(store, "imap-credentials", "IMAP credentials", Credentials{
|
||||||
|
Host: "imap.example.com",
|
||||||
|
Username: "alice",
|
||||||
|
Password: token,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := secretstore.GetJSON[Credentials](store, "imap-credentials")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`.
|
||||||
|
Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`.
|
||||||
|
|
||||||
## Helpers CLI
|
## Helpers CLI
|
||||||
|
|
||||||
`cli` fournit des helpers simples pour les assistants interactifs :
|
`cli` fournit des helpers simples pour les assistants interactifs :
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,36 @@
|
||||||
package secretstore
|
package secretstore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/99designs/keyring"
|
"github.com/99designs/keyring"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNotFound = errors.New("secret not found")
|
var ErrNotFound = errors.New("secret not found")
|
||||||
|
var ErrBackendUnavailable = errors.New("secret backend unavailable")
|
||||||
|
var ErrReadOnly = errors.New("secret backend is read-only")
|
||||||
|
|
||||||
|
type BackendPolicy string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BackendAuto BackendPolicy = "auto"
|
||||||
|
BackendKWalletOnly BackendPolicy = "kwallet-only"
|
||||||
|
BackendKeyringAny BackendPolicy = "keyring-any"
|
||||||
|
BackendEnvOnly BackendPolicy = "env-only"
|
||||||
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
ServiceName string
|
ServiceName string
|
||||||
|
BackendPolicy BackendPolicy
|
||||||
|
LookupEnv func(string) (string, bool)
|
||||||
|
KWalletAppID string
|
||||||
|
KWalletFolder string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Store interface {
|
type Store interface {
|
||||||
|
|
@ -21,28 +39,91 @@ type Store interface {
|
||||||
DeleteSecret(name string) error
|
DeleteSecret(name string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BackendUnavailableError struct {
|
||||||
|
Policy BackendPolicy
|
||||||
|
Required string
|
||||||
|
Available []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BackendUnavailableError) Error() string {
|
||||||
|
if len(e.Available) == 0 {
|
||||||
|
return fmt.Sprintf("secret backend policy %q requires %s, but no compatible backend is available", e.Policy, e.Required)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"secret backend policy %q requires %s, but only [%s] are available",
|
||||||
|
e.Policy,
|
||||||
|
e.Required,
|
||||||
|
strings.Join(e.Available, ", "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BackendUnavailableError) Unwrap() error {
|
||||||
|
return ErrBackendUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
type keyringStore struct {
|
type keyringStore struct {
|
||||||
ring keyring.Keyring
|
ring keyring.Keyring
|
||||||
serviceName string
|
serviceName string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type envStore struct {
|
||||||
|
lookupEnv func(string) (string, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
openKeyring = keyring.Open
|
||||||
|
availableKeyringPolicy = keyring.AvailableBackends
|
||||||
|
)
|
||||||
|
|
||||||
func Open(options Options) (Store, error) {
|
func Open(options Options) (Store, error) {
|
||||||
|
policy := options.BackendPolicy
|
||||||
|
if policy == "" {
|
||||||
|
policy = BackendAuto
|
||||||
|
}
|
||||||
|
|
||||||
|
switch policy {
|
||||||
|
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly:
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid secret backend policy %q", policy)
|
||||||
|
}
|
||||||
|
|
||||||
|
if policy == BackendEnvOnly {
|
||||||
|
return newEnvStore(options), nil
|
||||||
|
}
|
||||||
|
|
||||||
serviceName := strings.TrimSpace(options.ServiceName)
|
serviceName := strings.TrimSpace(options.ServiceName)
|
||||||
if serviceName == "" {
|
if serviceName == "" {
|
||||||
return nil, errors.New("service name must not be empty")
|
return nil, errors.New("service name must not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
ring, err := keyring.Open(keyring.Config{
|
available := availableKeyringPolicy()
|
||||||
ServiceName: serviceName,
|
allowed, err := allowedBackends(policy, available)
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open OS wallet backend %q for service %q: %w", BackendName(), serviceName, err)
|
if errors.Is(err, ErrBackendUnavailable) && policy == BackendAuto && options.LookupEnv != nil {
|
||||||
|
return newEnvStore(options), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &keyringStore{
|
ring, err := openKeyring(keyring.Config{
|
||||||
ring: ring,
|
ServiceName: serviceName,
|
||||||
serviceName: serviceName,
|
AllowedBackends: allowed,
|
||||||
}, nil
|
KWalletAppID: strings.TrimSpace(options.KWalletAppID),
|
||||||
|
KWalletFolder: strings.TrimSpace(options.KWalletFolder),
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
return &keyringStore{
|
||||||
|
ring: ring,
|
||||||
|
serviceName: serviceName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if policy == BackendAuto && options.LookupEnv != nil {
|
||||||
|
return newEnvStore(options), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("open secret backend for service %q with policy %q: %w", serviceName, policy, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func BackendName() string {
|
func BackendName() string {
|
||||||
|
|
@ -58,6 +139,38 @@ func BackendName() string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetJSON[T any](store Store, name, label string, value T) error {
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode structured secret %q as JSON: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.SetSecret(name, label, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetJSON[T any](store Store, name string) (T, error) {
|
||||||
|
var value T
|
||||||
|
if err := GetJSONInto(store, name, &value); err != nil {
|
||||||
|
var zero T
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetJSONInto(store Store, name string, target any) error {
|
||||||
|
secret, err := store.GetSecret(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(secret), target); err != nil {
|
||||||
|
return fmt.Errorf("decode structured secret %q from JSON: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *keyringStore) SetSecret(name, label, secret string) error {
|
func (s *keyringStore) SetSecret(name, label, secret string) error {
|
||||||
if err := s.ring.Set(keyring.Item{
|
if err := s.ring.Set(keyring.Item{
|
||||||
Key: name,
|
Key: name,
|
||||||
|
|
@ -88,3 +201,62 @@ func (s *keyringStore) DeleteSecret(name string) error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *envStore) SetSecret(name, label, secret string) error {
|
||||||
|
return fmt.Errorf("save secret %q in environment backend: %w", name, ErrReadOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *envStore) GetSecret(name string) (string, error) {
|
||||||
|
value, ok := s.lookupEnv(name)
|
||||||
|
if !ok {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *envStore) DeleteSecret(name string) error {
|
||||||
|
return fmt.Errorf("delete secret %q from environment backend: %w", name, ErrReadOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEnvStore(options Options) Store {
|
||||||
|
lookupEnv := options.LookupEnv
|
||||||
|
if lookupEnv == nil {
|
||||||
|
lookupEnv = os.LookupEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
return &envStore{lookupEnv: lookupEnv}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowedBackends(policy BackendPolicy, available []keyring.BackendType) ([]keyring.BackendType, error) {
|
||||||
|
switch policy {
|
||||||
|
case BackendAuto, BackendKeyringAny:
|
||||||
|
if len(available) == 0 {
|
||||||
|
return nil, &BackendUnavailableError{
|
||||||
|
Policy: policy,
|
||||||
|
Required: "any keyring backend",
|
||||||
|
Available: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slices.Clone(available), nil
|
||||||
|
case BackendKWalletOnly:
|
||||||
|
if !slices.Contains(available, keyring.KWalletBackend) {
|
||||||
|
return nil, &BackendUnavailableError{
|
||||||
|
Policy: policy,
|
||||||
|
Required: string(keyring.KWalletBackend),
|
||||||
|
Available: backendNames(available),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []keyring.BackendType{keyring.KWalletBackend}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid secret backend policy %q", policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func backendNames(backends []keyring.BackendType) []string {
|
||||||
|
names := make([]string, 0, len(backends))
|
||||||
|
for _, backend := range backends {
|
||||||
|
names = append(names, string(backend))
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
|
||||||
247
secretstore/store_test.go
Normal file
247
secretstore/store_test.go
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue