package cli import ( "bytes" "context" "errors" "fmt" "io" "os" "path/filepath" "strings" "testing" frameworkconfig "forge.lclr.dev/AI/mcp-framework/config" frameworkmanifest "forge.lclr.dev/AI/mcp-framework/manifest" frameworksecretstore "forge.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 deleteErr error setName string setValue string setCalled bool delName string delCalled 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 { s.delCalled = true s.delName = name if s.deleteErr != nil { return s.deleteErr } 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) { output := &bytes.Buffer{} app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "dev") if err := app.Run(nil); err != nil { t.Fatalf("expected help to be rendered, got error %v", err) } text := output.String() for _, snippet := range []string{"Usage:", "config", "version", `doctor Diagnostiquer la configuration locale. (alias de "config test").`} { if !strings.Contains(text, snippet) { t.Fatalf("help output missing %q: %q", snippet, text) } } } func TestAppRunShowsManifestBootstrapMetadataInHelp(t *testing.T) { output := &bytes.Buffer{} app := NewAppWithDependencies( nil, nil, nil, nil, nil, func(string) (frameworkmanifest.File, string, error) { return frameworkmanifest.File{ BinaryName: "email-mcp-custom", Bootstrap: frameworkmanifest.Bootstrap{ Description: "Custom manifest description", }, }, "/tmp/mcp.toml", nil }, func() (string, error) { return "/tmp/bin/email-mcp-custom", nil }, nil, output, &bytes.Buffer{}, "dev", ) if err := app.Run(nil); err != nil { t.Fatalf("expected help to be rendered, got error %v", err) } text := output.String() for _, snippet := range []string{"Custom manifest description", "email-mcp-custom ", "email-mcp-custom help "} { if !strings.Contains(text, snippet) { t.Fatalf("help output missing %q: %q", snippet, text) } } } func TestAppRunVersionPrintsBuildVersion(t *testing.T) { output := &bytes.Buffer{} app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "v1.2.3") if err := app.Run([]string{"version"}); err != nil { t.Fatalf("version returned error: %v", err) } if got := output.String(); got != "v1.2.3\n" { t.Fatalf("version output = %q, want %q", got, "v1.2.3\n") } } func TestAppRunDoctorHelp(t *testing.T) { output := &bytes.Buffer{} app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "dev") if err := app.Run([]string{"doctor", "--help"}); err != nil { t.Fatalf("doctor help returned error: %v", err) } if got := output.String(); !strings.Contains(got, "email-mcp config test [args]") { t.Fatalf("unexpected doctor help output: %q", got) } } func TestAppRunHelpDoctorUsesConfigTestHelp(t *testing.T) { output := &bytes.Buffer{} app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "dev") if err := app.Run([]string{"help", "doctor"}); err != nil { t.Fatalf("help doctor returned error: %v", err) } if got := output.String(); !strings.Contains(got, "email-mcp config test [args]") { t.Fatalf("unexpected doctor help output: %q", got) } } func TestAppRunSetupPromptsAndSavesProfile(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{"setup"}); 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 TestAppRunSetupUsesManifestDefaultProfile(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, func(string) (frameworkmanifest.File, string, error) { return frameworkmanifest.File{ Profiles: frameworkmanifest.Profiles{ Default: "work", }, }, "/tmp/mcp.toml", nil }, func() (string, error) { return "/tmp/bin/email-mcp", nil }, nil, output, &bytes.Buffer{}, "dev", ) if err := app.Run([]string{"setup"}); err != nil { t.Fatalf("Run returned error: %v", err) } if !secrets.setCalled { t.Fatal("expected password to be stored") } if secrets.setName != "imap-password/work" { t.Fatalf("unexpected secret name %q", secrets.setName) } if cfgStore.saved.CurrentProfile != "work" { t.Fatalf("current profile = %q, want work", cfgStore.saved.CurrentProfile) } if got := output.String(); !strings.Contains(got, `profile "work" saved`) { t.Fatalf("unexpected output %q", got) } } func TestAppRunConfigRequiresSubcommand(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", ) err := app.Run([]string{"config"}) if err == nil { t.Fatal("expected config without subcommand to fail") } if !strings.Contains(err.Error(), "subcommand is required") { t.Fatalf("unexpected error: %v", err) } if cfgStore.saveCalled { t.Fatal("config without subcommand must not save configuration") } } func TestAppRunConfigDeleteRemovesProfileAndSecret(t *testing.T) { cfgStore := &configStoreStub{ cfg: frameworkconfig.FileConfig[ProfileConfig]{ Version: frameworkconfig.CurrentVersion, CurrentProfile: "work", Profiles: map[string]ProfileConfig{ "work": { Host: "imap.work.example.com", Username: "alice", }, "default": { Host: "imap.default.example.com", Username: "alice", }, }, }, } secrets := &secretStoreStub{ values: map[string]string{ "imap-password/work": "secret-work", }, } output := &bytes.Buffer{} app := NewAppWithDependencies( nil, cfgStore, func() (secretStore, error) { return secrets, nil }, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "dev", ) if err := app.Run([]string{"config", "delete", "--profile", "work"}); err != nil { t.Fatalf("config delete returned error: %v", err) } if !secrets.delCalled { t.Fatal("expected password secret to be deleted") } if secrets.delName != "imap-password/work" { t.Fatalf("deleted secret = %q, want %q", secrets.delName, "imap-password/work") } if _, ok := cfgStore.saved.Profiles["work"]; ok { t.Fatalf("profile work should have been removed, got %#v", cfgStore.saved.Profiles) } if cfgStore.saved.CurrentProfile != "default" { t.Fatalf("current profile = %q, want default", cfgStore.saved.CurrentProfile) } text := output.String() for _, needle := range []string{`profile "work" deleted`, "current profile: default"} { if !strings.Contains(text, needle) { t.Fatalf("output = %q, want substring %q", text, needle) } } } func TestAppRunConfigDeleteIgnoresReadOnlySecretBackend(t *testing.T) { cfgStore := &configStoreStub{ cfg: frameworkconfig.FileConfig[ProfileConfig]{ Version: frameworkconfig.CurrentVersion, CurrentProfile: "work", Profiles: map[string]ProfileConfig{ "work": { Host: "imap.work.example.com", Username: "alice", }, }, }, } secrets := &secretStoreStub{ deleteErr: frameworksecretstore.ErrReadOnly, } output := &bytes.Buffer{} app := NewAppWithDependencies( nil, cfgStore, func() (secretStore, error) { return secrets, nil }, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "dev", ) if err := app.Run([]string{"config", "delete", "--profile", "work"}); err != nil { t.Fatalf("config delete returned error: %v", err) } if _, ok := cfgStore.saved.Profiles["work"]; ok { t.Fatalf("profile work should have been removed, got %#v", cfgStore.saved.Profiles) } text := output.String() for _, needle := range []string{ "secret backend is read-only; EMAIL_MCP_PASSWORD cannot be deleted automatically", `profile "work" deleted`, } { if !strings.Contains(text, needle) { t.Fatalf("output = %q, want substring %q", text, needle) } } } func TestAppRunSetupUsesStoredValuesAsDefaults(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{"setup"}); err != nil { t.Fatalf("setup 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 TestAppRunSetupAllowsReadOnlySecretBackendWhenPasswordEnvIsSet(t *testing.T) { t.Setenv(passwordEnv, "env-secret") prompter := &configPrompterStub{ credential: secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "new-secret", }, } cfgStore := &configStoreStub{} secrets := &secretStoreStub{setErr: frameworksecretstore.ErrReadOnly} 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{"setup"}); err != nil { t.Fatalf("setup returned error: %v", err) } if !secrets.setCalled { t.Fatal("expected password write attempt") } if !cfgStore.saveCalled { t.Fatal("expected config to be saved") } if !strings.Contains(output.String(), "secret backend is read-only; password is provided via EMAIL_MCP_PASSWORD") { t.Fatalf("unexpected output: %q", output.String()) } } func TestAppRunSetupFailsOnReadOnlySecretBackendWithoutPasswordEnv(t *testing.T) { prompter := &configPrompterStub{ credential: secretstore.Credential{ Host: "imap.example.com", Username: "alice", Password: "new-secret", }, } cfgStore := &configStoreStub{} secrets := &secretStoreStub{setErr: frameworksecretstore.ErrReadOnly} app := NewAppWithDependencies( prompter, cfgStore, func() (secretStore, error) { return secrets, nil }, nil, nil, nil, nil, nil, io.Discard, &bytes.Buffer{}, "dev", ) err := app.Run([]string{"setup"}) if err == nil { t.Fatal("expected setup to fail") } if !strings.Contains(err.Error(), "secret backend is read-only; set EMAIL_MCP_PASSWORD and rerun `email-mcp setup`") { t.Fatalf("unexpected error: %v", err) } if cfgStore.saveCalled { t.Fatal("config must not be saved when password cannot be persisted") } } func TestAppRunConfigShowPrintsResolvedConfiguration(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", }, } output := &bytes.Buffer{} app := NewAppWithDependencies( nil, cfgStore, func() (secretStore, error) { return secrets, nil }, nil, nil, nil, nil, nil, output, &bytes.Buffer{}, "dev", ) if err := app.Run([]string{"config", "show"}); err != nil { t.Fatalf("config show returned error: %v", err) } text := output.String() for _, needle := range []string{ "profile: work", "host: imap.example.com (config)", "username: alice (config)", "password: (secret)", } { if !strings.Contains(text, needle) { t.Fatalf("output = %q, want substring %q", text, needle) } } } func TestAppRunConfigTestDelegatesToDoctor(t *testing.T) { app := NewAppWithDependencies(nil, nil, nil, nil, nil, nil, nil, nil, nil, &bytes.Buffer{}, "dev") err := app.Run([]string{"config", "test"}) if err == nil { t.Fatal("expected config test to fail without dependencies") } if !strings.Contains(err.Error(), "config store is not configured") { t.Fatalf("unexpected error: %v", err) } } 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 TestAppRunMCPPrefersEnvironmentCredentialValues(t *testing.T) { t.Setenv(hostEnv, "imap.env.example.com") t.Setenv(usernameEnv, "alice-env") t.Setenv(passwordEnv, "secret-env") cfgStore := &configStoreStub{ cfg: frameworkconfig.FileConfig[ProfileConfig]{ Version: frameworkconfig.CurrentVersion, CurrentProfile: "work", Profiles: map[string]ProfileConfig{ "work": { Host: "imap.config.example.com", Username: "alice-config", }, }, }, } secrets := &secretStoreStub{ values: map[string]string{ "imap-password/work": "secret-wallet", }, } runner := &runnerStub{} var gotCredential secretstore.Credential app := NewAppWithDependencies( nil, cfgStore, func() (secretStore, error) { return secrets, nil }, func() mcpserver.MailService { return wireMailServiceStub{} }, func(cred secretstore.Credential, _ mcpserver.MailService, _ io.Reader, _ io.Writer, _ io.Writer) MCPRunner { gotCredential = cred 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") } want := secretstore.Credential{ Host: "imap.env.example.com", Username: "alice-env", Password: "secret-env", } if gotCredential != want { t.Fatalf("credential = %#v, want %#v", gotCredential, want) } } func TestAppRunMCPUsesEnvironmentCredentialWithoutSavedProfile(t *testing.T) { t.Setenv(hostEnv, "imap.env.example.com") t.Setenv(usernameEnv, "alice-env") t.Setenv(passwordEnv, "secret-env") cfgStore := &configStoreStub{ cfg: frameworkconfig.FileConfig[ProfileConfig]{ Version: frameworkconfig.CurrentVersion, }, } secrets := &secretStoreStub{} runner := &runnerStub{} var gotCredential secretstore.Credential app := NewAppWithDependencies( nil, cfgStore, func() (secretStore, error) { return secrets, nil }, func() mcpserver.MailService { return wireMailServiceStub{} }, func(cred secretstore.Credential, _ mcpserver.MailService, _ io.Reader, _ io.Writer, _ io.Writer) MCPRunner { gotCredential = cred 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.Host != "imap.env.example.com" || gotCredential.Username != "alice-env" || gotCredential.Password != "secret-env" { t.Fatalf("unexpected credential %#v", gotCredential) } } 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" driver = "gitea" repository = "AI/email-mcp" base_url = "http://127.0.0.1:1" `), 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) } } type doctorMailServiceStub struct { listMailboxes []imapclient.Mailbox listErr error called bool } func (s *doctorMailServiceStub) ListMailboxes(context.Context, secretstore.Credential) ([]imapclient.Mailbox, error) { s.called = true return s.listMailboxes, s.listErr } func (s *doctorMailServiceStub) ListMessages(context.Context, secretstore.Credential, string, int) ([]imapclient.MessageSummary, error) { return nil, nil } func (s *doctorMailServiceStub) GetMessage(context.Context, secretstore.Credential, string, uint32) (imapclient.Message, error) { return imapclient.Message{}, nil } func TestAppRunDoctorRendersReportAndChecksConnectivity(t *testing.T) { tempHome := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tempHome) t.Setenv("HOME", tempHome) store := frameworkconfig.NewStore[ProfileConfig](binaryName) configPath, err := store.ConfigPath() if err != nil { t.Fatalf("ConfigPath returned error: %v", err) } if err := store.Save(configPath, frameworkconfig.FileConfig[ProfileConfig]{ Version: frameworkconfig.CurrentVersion, CurrentProfile: "work", Profiles: map[string]ProfileConfig{ "work": { Host: "imap.example.com", Username: "alice", }, }, }); err != nil { t.Fatalf("Save returned error: %v", err) } manifestDir := t.TempDir() if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(` [update] driver = "gitea" repository = "AI/email-mcp" base_url = "https://gitea.lclr.dev" `), 0o600); err != nil { t.Fatalf("WriteFile returned error: %v", err) } secrets := &secretStoreStub{ values: map[string]string{ "imap-password/work": "secret", }, } mail := &doctorMailServiceStub{ listMailboxes: []imapclient.Mailbox{{Name: "INBOX"}}, } output := &bytes.Buffer{} app := NewAppWithDependencies( nil, store, func() (secretStore, error) { return secrets, nil }, func() mcpserver.MailService { return mail }, nil, nil, func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil }, nil, output, &bytes.Buffer{}, "dev", ) if err := app.Run([]string{"doctor"}); err != nil { t.Fatalf("doctor returned error: %v", err) } if !mail.called { t.Fatal("expected connectivity check to call mail service") } text := output.String() for _, needle := range []string{ "[OK] config: config file is readable", "[OK] profile: required profile values are resolved", "[OK] password: stored password is present", "[OK] connectivity: IMAP server is reachable", "Summary: 6 ok, 0 warning(s), 0 failure(s), 6 total", } { if !strings.Contains(text, needle) { t.Fatalf("output = %q, want substring %q", text, needle) } } } func TestAppRunDoctorReturnsErrorWhenChecksFail(t *testing.T) { tempHome := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tempHome) t.Setenv("HOME", tempHome) store := frameworkconfig.NewStore[ProfileConfig](binaryName) configPath, err := store.ConfigPath() if err != nil { t.Fatalf("ConfigPath returned error: %v", err) } if err := store.Save(configPath, frameworkconfig.FileConfig[ProfileConfig]{ Version: frameworkconfig.CurrentVersion, Profiles: map[string]ProfileConfig{ "default": { Host: "imap.example.com", Username: "alice", }, }, }); err != nil { t.Fatalf("Save returned error: %v", err) } manifestDir := t.TempDir() if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(` [update] driver = "gitea" repository = "AI/email-mcp" base_url = "https://gitea.lclr.dev" `), 0o600); err != nil { t.Fatalf("WriteFile returned error: %v", err) } output := &bytes.Buffer{} app := NewAppWithDependencies( nil, store, func() (secretStore, error) { return &secretStoreStub{}, nil }, func() mcpserver.MailService { return &doctorMailServiceStub{} }, nil, nil, func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil }, nil, output, &bytes.Buffer{}, "dev", ) err = app.Run([]string{"doctor"}) if err == nil { t.Fatal("expected doctor to fail when password is missing") } if !strings.Contains(err.Error(), "doctor checks failed") { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(output.String(), "[FAIL] password: stored password is missing") { t.Fatalf("unexpected output: %q", output.String()) } } func TestAppRunDoctorAcceptsPasswordFromEnvironment(t *testing.T) { tempHome := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tempHome) t.Setenv("HOME", tempHome) t.Setenv(passwordEnv, "env-secret") store := frameworkconfig.NewStore[ProfileConfig](binaryName) configPath, err := store.ConfigPath() if err != nil { t.Fatalf("ConfigPath returned error: %v", err) } if err := store.Save(configPath, frameworkconfig.FileConfig[ProfileConfig]{ Version: frameworkconfig.CurrentVersion, CurrentProfile: "work", Profiles: map[string]ProfileConfig{ "work": { Host: "imap.example.com", Username: "alice", }, }, }); err != nil { t.Fatalf("Save returned error: %v", err) } manifestDir := t.TempDir() if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(` [update] driver = "gitea" repository = "AI/email-mcp" base_url = "https://gitea.lclr.dev" `), 0o600); err != nil { t.Fatalf("WriteFile returned error: %v", err) } output := &bytes.Buffer{} app := NewAppWithDependencies( nil, store, func() (secretStore, error) { return &secretStoreStub{}, nil }, func() mcpserver.MailService { return &doctorMailServiceStub{} }, nil, nil, func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil }, nil, output, &bytes.Buffer{}, "dev", ) if err := app.Run([]string{"doctor"}); err != nil { t.Fatalf("doctor returned error: %v", err) } if !strings.Contains(output.String(), "[OK] password: password is provided via environment") { t.Fatalf("unexpected output: %q", output.String()) } } func TestAppRunDoctorFailsWhenManifestUpdateConfigIsInvalid(t *testing.T) { tempHome := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tempHome) t.Setenv("HOME", tempHome) t.Setenv(passwordEnv, "env-secret") store := frameworkconfig.NewStore[ProfileConfig](binaryName) configPath, err := store.ConfigPath() if err != nil { t.Fatalf("ConfigPath returned error: %v", err) } if err := store.Save(configPath, frameworkconfig.FileConfig[ProfileConfig]{ Version: frameworkconfig.CurrentVersion, CurrentProfile: "work", Profiles: map[string]ProfileConfig{ "work": { Host: "imap.example.com", Username: "alice", }, }, }); err != nil { t.Fatalf("Save returned error: %v", err) } manifestDir := t.TempDir() if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte(` [update] driver = "gitea" base_url = "https://gitea.lclr.dev" `), 0o600); err != nil { t.Fatalf("WriteFile returned error: %v", err) } mail := &doctorMailServiceStub{ listMailboxes: []imapclient.Mailbox{{Name: "INBOX"}}, } output := &bytes.Buffer{} app := NewAppWithDependencies( nil, store, func() (secretStore, error) { return &secretStoreStub{}, nil }, func() mcpserver.MailService { return mail }, nil, nil, func() (string, error) { return filepath.Join(manifestDir, "email-mcp"), nil }, nil, output, &bytes.Buffer{}, "dev", ) err = app.Run([]string{"doctor"}) if err == nil { t.Fatal("expected doctor to fail with invalid manifest update config") } if !strings.Contains(err.Error(), "doctor checks failed") { t.Fatalf("unexpected error: %v", err) } text := output.String() if !strings.Contains(text, "[FAIL] manifest: manifest validation failed") { t.Fatalf("unexpected output: %q", text) } if !strings.Contains(text, "requires repository") { t.Fatalf("unexpected output: %q", text) } } 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: "setup", want: "config prompter is not configured"}, {command: "mcp", want: "mcp runner is not configured"}, {command: "doctor", want: "config store 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 setup`") { 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 TestMapAppErrorMapsUnavailableSecretBackendError(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()), "secret backend") { t.Fatalf("expected secret backend guidance, got %v", err) } } func TestExecuteSetupWritesMappedErrorAndReturnsExitCodeOne(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{"setup"}, stderr); code != 1 { t.Fatalf("expected exit code 1, got %d", code) } if got := strings.ToLower(stderr.String()); !strings.Contains(got, "secret backend") { 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 }