diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go new file mode 100644 index 0000000..e52614d --- /dev/null +++ b/internal/cli/integration_test.go @@ -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()) + } +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index dbb9718..c086646 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -267,7 +267,7 @@ func (s Server) loadCredential(ctx context.Context) (secretstore.Credential, err cred, err := s.store.Load(ctx, secretstore.DefaultAccountKey) 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{}, err diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index b7a2328..ee9ee7a 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -230,6 +230,7 @@ func TestRunnerRunReturnsFriendlyMissingCredentialError(t *testing.T) { store := &storeStub{ loadErr: kwallet.ErrCredentialNotFound, } + output := &bytes.Buffer{} runner := NewRunner(New(store, serviceStub{ listMailboxes: func(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { t.Fatal("ListMailboxes should not be called") @@ -243,12 +244,44 @@ func TestRunnerRunReturnsFriendlyMissingCredentialError(t *testing.T) { t.Fatal("GetMessage should not be called") return imapclient.Message{}, nil }, - }), bytes.NewBuffer(nil), &bytes.Buffer{}, &bytes.Buffer{}) + }), 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 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) {