feat: add cli setup boundary tests
This commit is contained in:
parent
e09b4afa0a
commit
afec7612aa
3 changed files with 193 additions and 2 deletions
158
internal/cli/integration_test.go
Normal file
158
internal/cli/integration_test.go
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"email-mcp/internal/imapclient"
|
||||||
|
"email-mcp/internal/mcpserver"
|
||||||
|
"email-mcp/internal/secretstore"
|
||||||
|
"email-mcp/internal/secretstore/kwallet"
|
||||||
|
)
|
||||||
|
|
||||||
|
type integrationPromptStub struct {
|
||||||
|
credential secretstore.Credential
|
||||||
|
called bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *integrationPromptStub) PromptSetup(context.Context) (secretstore.Credential, error) {
|
||||||
|
p.called = true
|
||||||
|
return p.credential, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type integrationStoreStub struct {
|
||||||
|
saved secretstore.Credential
|
||||||
|
savedKey string
|
||||||
|
saveCalled bool
|
||||||
|
loadErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *integrationStoreStub) Save(_ context.Context, key string, cred secretstore.Credential) error {
|
||||||
|
s.saveCalled = true
|
||||||
|
s.savedKey = key
|
||||||
|
s.saved = cred
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *integrationStoreStub) Load(context.Context, string) (secretstore.Credential, error) {
|
||||||
|
if s.loadErr != nil {
|
||||||
|
return secretstore.Credential{}, s.loadErr
|
||||||
|
}
|
||||||
|
return s.saved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type integrationMailServiceStub struct{}
|
||||||
|
|
||||||
|
func (integrationMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (integrationMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (integrationMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) {
|
||||||
|
return imapclient.Message{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type integrationRunnerStub struct {
|
||||||
|
called bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *integrationRunnerStub) Run(context.Context) error {
|
||||||
|
r.called = true
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppRunSetupPersistsPromptedCredential(t *testing.T) {
|
||||||
|
prompter := &integrationPromptStub{
|
||||||
|
credential: secretstore.Credential{
|
||||||
|
Host: "imap.example.com",
|
||||||
|
Username: "alice",
|
||||||
|
Password: "secret",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
store := &integrationStoreStub{}
|
||||||
|
runner := &integrationRunnerStub{}
|
||||||
|
|
||||||
|
app := buildApp(bytes.NewBuffer(nil), bytes.NewBuffer(nil), bytes.NewBuffer(nil), runtimeFactories{
|
||||||
|
newPrompter: func(io.Reader, io.Writer) SetupPrompter {
|
||||||
|
return prompter
|
||||||
|
},
|
||||||
|
newWalletClient: func() kwallet.Client {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
newStore: func(kwallet.Client) secretstore.Store {
|
||||||
|
return store
|
||||||
|
},
|
||||||
|
newMailService: func() mcpserver.MailService {
|
||||||
|
return integrationMailServiceStub{}
|
||||||
|
},
|
||||||
|
newRunner: func(secretstore.Store, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner {
|
||||||
|
return runner
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := app.Run([]string{"setup"}); err != nil {
|
||||||
|
t.Fatalf("setup returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !prompter.called {
|
||||||
|
t.Fatal("expected setup prompter to be called")
|
||||||
|
}
|
||||||
|
if !store.saveCalled {
|
||||||
|
t.Fatal("expected setup to persist credentials")
|
||||||
|
}
|
||||||
|
if store.savedKey != secretstore.DefaultAccountKey {
|
||||||
|
t.Fatalf("expected save key %q, got %q", secretstore.DefaultAccountKey, store.savedKey)
|
||||||
|
}
|
||||||
|
if store.saved != prompter.credential {
|
||||||
|
t.Fatalf("unexpected saved credential: %#v", store.saved)
|
||||||
|
}
|
||||||
|
if runner.called {
|
||||||
|
t.Fatal("setup should not invoke the MCP runner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppRunMCPReturnsSetupHintWhenCredentialsAreMissing(t *testing.T) {
|
||||||
|
store := &integrationStoreStub{
|
||||||
|
loadErr: kwallet.ErrCredentialNotFound,
|
||||||
|
}
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
mail := integrationMailServiceStub{}
|
||||||
|
|
||||||
|
app := buildApp(bytes.NewBuffer(nil), output, &bytes.Buffer{}, runtimeFactories{
|
||||||
|
newPrompter: func(io.Reader, io.Writer) SetupPrompter {
|
||||||
|
return &integrationPromptStub{}
|
||||||
|
},
|
||||||
|
newWalletClient: func() kwallet.Client {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
newStore: func(kwallet.Client) secretstore.Store {
|
||||||
|
return store
|
||||||
|
},
|
||||||
|
newMailService: func() mcpserver.MailService {
|
||||||
|
return mail
|
||||||
|
},
|
||||||
|
newRunner: func(secretstore.Store, mcpserver.MailService, io.Reader, io.Writer, io.Writer) MCPRunner {
|
||||||
|
return mcpserver.NewRunner(mcpserver.New(store, mail), nil, output, &bytes.Buffer{})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := app.Run([]string{"mcp"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, mcpserver.ErrCredentialsNotConfigured) {
|
||||||
|
t.Fatalf("expected credentials-not-configured sentinel, got %v", err)
|
||||||
|
}
|
||||||
|
if err.Error() != "credentials not configured; run `email-mcp setup`" {
|
||||||
|
t.Fatalf("unexpected user-facing error: %q", err.Error())
|
||||||
|
}
|
||||||
|
if output.Len() != 0 {
|
||||||
|
t.Fatalf("expected no MCP protocol output on missing credentials, got %q", output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -267,7 +267,7 @@ func (s Server) loadCredential(ctx context.Context) (secretstore.Credential, err
|
||||||
|
|
||||||
cred, err := s.store.Load(ctx, secretstore.DefaultAccountKey)
|
cred, err := s.store.Load(ctx, secretstore.DefaultAccountKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, kwallet.ErrCredentialNotFound) {
|
if errors.Is(err, kwallet.ErrCredentialNotFound) || errors.Is(err, ErrCredentialsNotConfigured) {
|
||||||
return secretstore.Credential{}, ErrCredentialsNotConfigured
|
return secretstore.Credential{}, ErrCredentialsNotConfigured
|
||||||
}
|
}
|
||||||
return secretstore.Credential{}, err
|
return secretstore.Credential{}, err
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,7 @@ func TestRunnerRunReturnsFriendlyMissingCredentialError(t *testing.T) {
|
||||||
store := &storeStub{
|
store := &storeStub{
|
||||||
loadErr: kwallet.ErrCredentialNotFound,
|
loadErr: kwallet.ErrCredentialNotFound,
|
||||||
}
|
}
|
||||||
|
output := &bytes.Buffer{}
|
||||||
runner := NewRunner(New(store, serviceStub{
|
runner := NewRunner(New(store, serviceStub{
|
||||||
listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
||||||
t.Fatal("ListMailboxes should not be called")
|
t.Fatal("ListMailboxes should not be called")
|
||||||
|
|
@ -243,12 +244,44 @@ func TestRunnerRunReturnsFriendlyMissingCredentialError(t *testing.T) {
|
||||||
t.Fatal("GetMessage should not be called")
|
t.Fatal("GetMessage should not be called")
|
||||||
return imapclient.Message{}, nil
|
return imapclient.Message{}, nil
|
||||||
},
|
},
|
||||||
}), bytes.NewBuffer(nil), &bytes.Buffer{}, &bytes.Buffer{})
|
}), bytes.NewBuffer(nil), output, &bytes.Buffer{})
|
||||||
|
|
||||||
err := runner.Run(context.Background())
|
err := runner.Run(context.Background())
|
||||||
if !errors.Is(err, ErrCredentialsNotConfigured) {
|
if !errors.Is(err, ErrCredentialsNotConfigured) {
|
||||||
t.Fatalf("expected missing credential error, got %v", err)
|
t.Fatalf("expected missing credential error, got %v", err)
|
||||||
}
|
}
|
||||||
|
if output.Len() != 0 {
|
||||||
|
t.Fatalf("expected no output when credentials are missing, got %q", output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerRunReturnsFriendlyMissingCredentialErrorWhenStoreAlreadyTranslatedIt(t *testing.T) {
|
||||||
|
store := &storeStub{
|
||||||
|
loadErr: ErrCredentialsNotConfigured,
|
||||||
|
}
|
||||||
|
output := &bytes.Buffer{}
|
||||||
|
runner := NewRunner(New(store, serviceStub{
|
||||||
|
listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
||||||
|
t.Fatal("ListMailboxes should not be called")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
listMessages: func(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) {
|
||||||
|
t.Fatal("ListMessages should not be called")
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
getMessage: func(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) {
|
||||||
|
t.Fatal("GetMessage should not be called")
|
||||||
|
return imapclient.Message{}, nil
|
||||||
|
},
|
||||||
|
}), bytes.NewBuffer(nil), output, &bytes.Buffer{})
|
||||||
|
|
||||||
|
err := runner.Run(context.Background())
|
||||||
|
if !errors.Is(err, ErrCredentialsNotConfigured) {
|
||||||
|
t.Fatalf("expected missing credential error, got %v", err)
|
||||||
|
}
|
||||||
|
if output.Len() != 0 {
|
||||||
|
t.Fatalf("expected no output when credentials are missing, got %q", output.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerToolsAdvertiseValidatedArgumentContracts(t *testing.T) {
|
func TestServerToolsAdvertiseValidatedArgumentContracts(t *testing.T) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue