497 lines
12 KiB
Go
497 lines
12 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
frameworkconfig "gitea.lclr.dev/AI/mcp-framework/config"
|
|
frameworkmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
|
|
frameworksecretstore "gitea.lclr.dev/AI/mcp-framework/secretstore"
|
|
|
|
"email-mcp/internal/imapclient"
|
|
"email-mcp/internal/mcpserver"
|
|
"email-mcp/internal/secretstore"
|
|
)
|
|
|
|
var _ func() *App = NewApp
|
|
|
|
type configPrompterStub struct {
|
|
credential secretstore.Credential
|
|
err error
|
|
called bool
|
|
existing secretstore.Credential
|
|
hasStored bool
|
|
}
|
|
|
|
func (p *configPrompterStub) PromptCredential(context.Context, secretstore.Credential, bool) (secretstore.Credential, error) {
|
|
p.called = true
|
|
if p.err != nil {
|
|
return secretstore.Credential{}, p.err
|
|
}
|
|
return p.credential, nil
|
|
}
|
|
|
|
type capturingPrompterStub struct {
|
|
credential secretstore.Credential
|
|
existing secretstore.Credential
|
|
hasStored bool
|
|
}
|
|
|
|
func (p *capturingPrompterStub) PromptCredential(_ context.Context, existing secretstore.Credential, hasStored bool) (secretstore.Credential, error) {
|
|
p.existing = existing
|
|
p.hasStored = hasStored
|
|
return p.credential, nil
|
|
}
|
|
|
|
type configStoreStub struct {
|
|
cfg frameworkconfig.FileConfig[ProfileConfig]
|
|
loadErr error
|
|
saveErr error
|
|
saved frameworkconfig.FileConfig[ProfileConfig]
|
|
saveCalled bool
|
|
configPath string
|
|
}
|
|
|
|
func (s *configStoreStub) LoadDefault() (frameworkconfig.FileConfig[ProfileConfig], string, error) {
|
|
if s.loadErr != nil {
|
|
return frameworkconfig.FileConfig[ProfileConfig]{}, "", s.loadErr
|
|
}
|
|
path := s.configPath
|
|
if path == "" {
|
|
path = "/tmp/email-mcp/config.json"
|
|
}
|
|
return s.cfg, path, nil
|
|
}
|
|
|
|
func (s *configStoreStub) SaveDefault(cfg frameworkconfig.FileConfig[ProfileConfig]) (string, error) {
|
|
s.saveCalled = true
|
|
s.saved = cfg
|
|
if s.saveErr != nil {
|
|
return "", s.saveErr
|
|
}
|
|
path := s.configPath
|
|
if path == "" {
|
|
path = "/tmp/email-mcp/config.json"
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
type secretStoreStub struct {
|
|
values map[string]string
|
|
setErr error
|
|
getErr error
|
|
setName string
|
|
setValue string
|
|
setCalled bool
|
|
}
|
|
|
|
func (s *secretStoreStub) SetSecret(name, _ string, secret string) error {
|
|
s.setCalled = true
|
|
s.setName = name
|
|
s.setValue = secret
|
|
if s.setErr != nil {
|
|
return s.setErr
|
|
}
|
|
if s.values == nil {
|
|
s.values = map[string]string{}
|
|
}
|
|
s.values[name] = secret
|
|
return nil
|
|
}
|
|
|
|
func (s *secretStoreStub) GetSecret(name string) (string, error) {
|
|
if s.getErr != nil {
|
|
return "", s.getErr
|
|
}
|
|
value, ok := s.values[name]
|
|
if !ok {
|
|
return "", frameworksecretstore.ErrNotFound
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func (s *secretStoreStub) DeleteSecret(name string) error {
|
|
delete(s.values, name)
|
|
return nil
|
|
}
|
|
|
|
type runnerStub struct {
|
|
err error
|
|
called bool
|
|
}
|
|
|
|
func (r *runnerStub) Run(context.Context) error {
|
|
r.called = true
|
|
return r.err
|
|
}
|
|
|
|
func TestAppRunRejectsUnknownCommand(t *testing.T) {
|
|
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev")
|
|
|
|
err := app.Run([]string{"unknown"})
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown command")
|
|
}
|
|
}
|
|
|
|
func TestAppRunShowsUsageWhenNoArgsProvided(t *testing.T) {
|
|
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev")
|
|
|
|
err := app.Run(nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing command")
|
|
}
|
|
if !strings.Contains(err.Error(), "usage:") {
|
|
t.Fatalf("expected usage text in error, got %q", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestAppRunConfigPromptsAndSavesProfile(t *testing.T) {
|
|
prompter := &configPrompterStub{
|
|
credential: secretstore.Credential{
|
|
Host: "imap.example.com",
|
|
Username: "alice",
|
|
Password: "secret",
|
|
},
|
|
}
|
|
cfgStore := &configStoreStub{}
|
|
secrets := &secretStoreStub{}
|
|
output := &bytes.Buffer{}
|
|
|
|
app := NewAppWithDependencies(
|
|
prompter,
|
|
cfgStore,
|
|
func() (secretStore, error) { return secrets, nil },
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
output,
|
|
&bytes.Buffer{},
|
|
"dev",
|
|
)
|
|
|
|
if err := app.Run([]string{"config"}); err != nil {
|
|
t.Fatalf("Run returned error: %v", err)
|
|
}
|
|
|
|
if !prompter.called {
|
|
t.Fatal("expected config prompter to be called")
|
|
}
|
|
if !secrets.setCalled {
|
|
t.Fatal("expected password to be stored")
|
|
}
|
|
if secrets.setName != "imap-password/default" {
|
|
t.Fatalf("unexpected secret name %q", secrets.setName)
|
|
}
|
|
if !cfgStore.saveCalled {
|
|
t.Fatal("expected config to be saved")
|
|
}
|
|
if cfgStore.saved.CurrentProfile != "default" {
|
|
t.Fatalf("current profile = %q, want default", cfgStore.saved.CurrentProfile)
|
|
}
|
|
if cfgStore.saved.Profiles["default"].Host != "imap.example.com" {
|
|
t.Fatalf("unexpected saved profile: %#v", cfgStore.saved.Profiles["default"])
|
|
}
|
|
if got := output.String(); !strings.Contains(got, `profile "default" saved`) {
|
|
t.Fatalf("unexpected output %q", got)
|
|
}
|
|
}
|
|
|
|
func TestAppRunSetupAliasesConfig(t *testing.T) {
|
|
prompter := &configPrompterStub{
|
|
credential: secretstore.Credential{
|
|
Host: "imap.example.com",
|
|
Username: "alice",
|
|
Password: "secret",
|
|
},
|
|
}
|
|
cfgStore := &configStoreStub{}
|
|
secrets := &secretStoreStub{}
|
|
|
|
app := NewAppWithDependencies(
|
|
prompter,
|
|
cfgStore,
|
|
func() (secretStore, error) { return secrets, nil },
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
io.Discard,
|
|
&bytes.Buffer{},
|
|
"dev",
|
|
)
|
|
|
|
if err := app.Run([]string{"setup"}); err != nil {
|
|
t.Fatalf("setup returned error: %v", err)
|
|
}
|
|
if !cfgStore.saveCalled {
|
|
t.Fatal("expected setup to save config via config command")
|
|
}
|
|
}
|
|
|
|
func TestAppRunConfigUsesStoredValuesAsDefaults(t *testing.T) {
|
|
prompter := &capturingPrompterStub{
|
|
credential: secretstore.Credential{
|
|
Host: "imap.example.com",
|
|
Username: "alice",
|
|
Password: "updated-secret",
|
|
},
|
|
}
|
|
cfgStore := &configStoreStub{
|
|
cfg: frameworkconfig.FileConfig[ProfileConfig]{
|
|
Version: frameworkconfig.CurrentVersion,
|
|
CurrentProfile: "work",
|
|
Profiles: map[string]ProfileConfig{
|
|
"work": {
|
|
Host: "imap.example.com",
|
|
Username: "alice",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
secrets := &secretStoreStub{
|
|
values: map[string]string{
|
|
"imap-password/work": "stored-secret",
|
|
},
|
|
}
|
|
|
|
app := NewAppWithDependencies(
|
|
prompter,
|
|
cfgStore,
|
|
func() (secretStore, error) { return secrets, nil },
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
io.Discard,
|
|
&bytes.Buffer{},
|
|
"dev",
|
|
)
|
|
|
|
if err := app.Run([]string{"config"}); err != nil {
|
|
t.Fatalf("config returned error: %v", err)
|
|
}
|
|
if !prompter.hasStored {
|
|
t.Fatal("expected stored password to be reported")
|
|
}
|
|
if prompter.existing.Password != "stored-secret" {
|
|
t.Fatalf("expected existing password to be forwarded, got %q", prompter.existing.Password)
|
|
}
|
|
}
|
|
|
|
func TestAppRunMCPDelegatesResolvedCredentialToRunner(t *testing.T) {
|
|
cfgStore := &configStoreStub{
|
|
cfg: frameworkconfig.FileConfig[ProfileConfig]{
|
|
Version: frameworkconfig.CurrentVersion,
|
|
CurrentProfile: "work",
|
|
Profiles: map[string]ProfileConfig{
|
|
"work": {
|
|
Host: "imap.example.com",
|
|
Username: "alice",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
secrets := &secretStoreStub{
|
|
values: map[string]string{
|
|
"imap-password/work": "secret",
|
|
},
|
|
}
|
|
runner := &runnerStub{}
|
|
var gotCredential secretstore.Credential
|
|
var gotMailService mcpserver.MailService
|
|
|
|
app := NewAppWithDependencies(
|
|
nil,
|
|
cfgStore,
|
|
func() (secretStore, error) { return secrets, nil },
|
|
func() mcpserver.MailService { return wireMailServiceStub{} },
|
|
func(cred secretstore.Credential, mail mcpserver.MailService, _ io.Reader, _ io.Writer, _ io.Writer) MCPRunner {
|
|
gotCredential = cred
|
|
gotMailService = mail
|
|
return runner
|
|
},
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
&bytes.Buffer{},
|
|
"dev",
|
|
)
|
|
|
|
if err := app.Run([]string{"mcp"}); err != nil {
|
|
t.Fatalf("mcp returned error: %v", err)
|
|
}
|
|
if !runner.called {
|
|
t.Fatal("expected runner to be called")
|
|
}
|
|
if gotCredential.Password != "secret" || gotCredential.Username != "alice" {
|
|
t.Fatalf("unexpected credential %#v", gotCredential)
|
|
}
|
|
if gotMailService == nil {
|
|
t.Fatal("expected mail service to be built")
|
|
}
|
|
}
|
|
|
|
func TestAppRunUpdateLoadsManifestNearExecutable(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
executablePath := filepath.Join(tempDir, "email-mcp")
|
|
if err := os.WriteFile(executablePath, []byte("old-binary"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile returned error: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(tempDir, "mcp.toml"), []byte(`
|
|
[update]
|
|
source_name = "test"
|
|
base_url = "http://127.0.0.1:1"
|
|
latest_release_url = "http://127.0.0.1:1/releases/latest"
|
|
`), 0o600); err != nil {
|
|
t.Fatalf("WriteFile manifest returned error: %v", err)
|
|
}
|
|
|
|
client := &bytes.Buffer{}
|
|
app := NewAppWithDependencies(
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
frameworkmanifest.LoadDefault,
|
|
func() (string, error) { return executablePath, nil },
|
|
nil,
|
|
client,
|
|
&bytes.Buffer{},
|
|
"dev",
|
|
)
|
|
|
|
err := app.Run([]string{"update"})
|
|
if err == nil {
|
|
t.Fatal("expected update to fail without a reachable release endpoint")
|
|
}
|
|
if !strings.Contains(err.Error(), "fetch latest release metadata") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAppRunReturnsClearErrorsWhenDependenciesMissing(t *testing.T) {
|
|
app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev")
|
|
|
|
tests := []struct {
|
|
command string
|
|
want string
|
|
}{
|
|
{command: "config", want: "config prompter is not configured"},
|
|
{command: "mcp", want: "mcp runner is not configured"},
|
|
{command: "update", want: "manifest loader is not configured"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.command, func(t *testing.T) {
|
|
err := app.Run([]string{tt.command})
|
|
if err == nil {
|
|
t.Fatalf("expected %s to fail without dependencies", tt.command)
|
|
}
|
|
if !strings.Contains(err.Error(), tt.want) {
|
|
t.Fatalf("expected error to contain %q, got %v", tt.want, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMapAppErrorMapsMissingCredentialError(t *testing.T) {
|
|
err := mapAppError(fmt.Errorf("%w: missing profile", mcpserver.ErrCredentialsNotConfigured))
|
|
if err == nil {
|
|
t.Fatal("expected mapped error")
|
|
}
|
|
if !strings.Contains(err.Error(), "run `email-mcp config`") {
|
|
t.Fatalf("expected config guidance, got %v", err)
|
|
}
|
|
if !errors.Is(err, mcpserver.ErrCredentialsNotConfigured) {
|
|
t.Fatalf("expected typed error to be preserved, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMapAppErrorMapsUnavailableWalletError(t *testing.T) {
|
|
err := mapAppError(&frameworksecretstore.BackendUnavailableError{
|
|
Policy: frameworksecretstore.BackendAuto,
|
|
Required: "any keyring backend",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected mapped error")
|
|
}
|
|
if !strings.Contains(strings.ToLower(err.Error()), "wallet") {
|
|
t.Fatalf("expected wallet guidance, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExecuteConfigWritesMappedErrorAndReturnsExitCodeOne(t *testing.T) {
|
|
app := NewAppWithDependencies(
|
|
&configPrompterStub{},
|
|
&configStoreStub{},
|
|
func() (secretStore, error) {
|
|
return nil, &frameworksecretstore.BackendUnavailableError{
|
|
Policy: frameworksecretstore.BackendAuto,
|
|
Required: "any keyring backend",
|
|
}
|
|
},
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
&bytes.Buffer{},
|
|
"dev",
|
|
)
|
|
|
|
stderr := &bytes.Buffer{}
|
|
if code := Execute(app, []string{"config"}, stderr); code != 1 {
|
|
t.Fatalf("expected exit code 1, got %d", code)
|
|
}
|
|
if got := strings.ToLower(stderr.String()); !strings.Contains(got, "wallet") {
|
|
t.Fatalf("unexpected stderr: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestNewAppBuildsProductionDependencies(t *testing.T) {
|
|
app := NewApp()
|
|
if app == nil {
|
|
t.Fatal("expected app instance")
|
|
}
|
|
if app.prompter == nil {
|
|
t.Fatal("expected config prompter to be configured")
|
|
}
|
|
if app.configStore == nil {
|
|
t.Fatal("expected config store to be configured")
|
|
}
|
|
if app.openSecretStore == nil {
|
|
t.Fatal("expected secret store opener to be configured")
|
|
}
|
|
if app.newRunner == nil {
|
|
t.Fatal("expected MCP runner factory to be configured")
|
|
}
|
|
}
|
|
|
|
type wireMailServiceStub struct{}
|
|
|
|
func (wireMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (wireMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (wireMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) {
|
|
return imapclient.Message{}, nil
|
|
}
|