From fadf41655fe929afcd900e5a4d5bb51181907286 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Fri, 10 Apr 2026 10:26:43 +0200 Subject: [PATCH] feat: add kwallet secret store adapter --- internal/secretstore/kwallet/store.go | 56 ++++++++++ internal/secretstore/kwallet/store_test.go | 114 +++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 internal/secretstore/kwallet/store.go create mode 100644 internal/secretstore/kwallet/store_test.go diff --git a/internal/secretstore/kwallet/store.go b/internal/secretstore/kwallet/store.go new file mode 100644 index 0000000..141dc87 --- /dev/null +++ b/internal/secretstore/kwallet/store.go @@ -0,0 +1,56 @@ +package kwallet + +import ( + "context" + + "email-mcp/internal/secretstore" +) + +type Client interface { + IsAvailable(ctx context.Context) error + Open(ctx context.Context) error + WriteEntry(ctx context.Context, key string, value []byte) error + ReadEntry(ctx context.Context, key string) ([]byte, error) +} + +type Store struct { + client Client +} + +var _ secretstore.Store = (*Store)(nil) + +func NewStore(client Client) *Store { + return &Store{client: client} +} + +func (s *Store) Save(ctx context.Context, key string, cred secretstore.Credential) error { + if err := s.client.IsAvailable(ctx); err != nil { + return err + } + if err := s.client.Open(ctx); err != nil { + return err + } + + data, err := secretstore.MarshalCredential(cred) + if err != nil { + return err + } + + return s.client.WriteEntry(ctx, key, data) +} + +func (s *Store) Load(ctx context.Context, key string) (secretstore.Credential, error) { + if err := s.client.IsAvailable(ctx); err != nil { + return secretstore.Credential{}, err + } + if err := s.client.Open(ctx); err != nil { + return secretstore.Credential{}, err + } + + data, err := s.client.ReadEntry(ctx, key) + if err != nil { + return secretstore.Credential{}, err + } + + return secretstore.UnmarshalCredential(data) +} diff --git a/internal/secretstore/kwallet/store_test.go b/internal/secretstore/kwallet/store_test.go new file mode 100644 index 0000000..5493388 --- /dev/null +++ b/internal/secretstore/kwallet/store_test.go @@ -0,0 +1,114 @@ +package kwallet + +import ( + "context" + "errors" + "testing" + + "email-mcp/internal/secretstore" +) + +type walletClientStub struct { + availableErr error + openErr error + writeErr error + readErr error + readValue []byte + + openCalled bool + writeKey string + writeValue []byte + readKey string +} + +func (c *walletClientStub) IsAvailable(context.Context) error { + return c.availableErr +} + +func (c *walletClientStub) Open(context.Context) error { + c.openCalled = true + return c.openErr +} + +func (c *walletClientStub) WriteEntry(_ context.Context, key string, value []byte) error { + c.writeKey = key + c.writeValue = value + return c.writeErr +} + +func (c *walletClientStub) ReadEntry(_ context.Context, key string) ([]byte, error) { + c.readKey = key + return c.readValue, c.readErr +} + +func TestStoreSaveWritesSerializedCredential(t *testing.T) { + client := &walletClientStub{} + store := NewStore(client) + + cred := secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "secret", + } + + if err := store.Save(context.Background(), secretstore.DefaultAccountKey, cred); err != nil { + t.Fatalf("Save returned error: %v", err) + } + + if !client.openCalled { + t.Fatal("expected wallet Open to be called") + } + if client.writeKey != secretstore.DefaultAccountKey { + t.Fatalf("unexpected key: %s", client.writeKey) + } + if len(client.writeValue) == 0 { + t.Fatal("expected serialized credential payload") + } +} + +func TestStoreSaveReturnsAvailabilityErrorWithoutOpeningWallet(t *testing.T) { + wantErr := errors.New("kwallet unavailable") + client := &walletClientStub{availableErr: wantErr} + store := NewStore(client) + + err := store.Save(context.Background(), secretstore.DefaultAccountKey, secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "secret", + }) + if !errors.Is(err, wantErr) { + t.Fatalf("expected availability error, got %v", err) + } + if client.openCalled { + t.Fatal("did not expect wallet Open when availability check fails") + } +} + +func TestStoreLoadReadsAndDecodesCredential(t *testing.T) { + payload, err := secretstore.MarshalCredential(secretstore.Credential{ + Host: "imap.example.com", + Username: "alice", + Password: "secret", + }) + if err != nil { + t.Fatalf("MarshalCredential returned error: %v", err) + } + + client := &walletClientStub{readValue: payload} + store := NewStore(client) + + cred, err := store.Load(context.Background(), secretstore.DefaultAccountKey) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if !client.openCalled { + t.Fatal("expected wallet Open to be called") + } + if client.readKey != secretstore.DefaultAccountKey { + t.Fatalf("unexpected key: %s", client.readKey) + } + if cred.Host != "imap.example.com" || cred.Username != "alice" || cred.Password != "secret" { + t.Fatalf("unexpected credential: %#v", cred) + } +}