email-mcp/internal/cli/app_test.go

1273 lines
32 KiB
Go

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 <command>", "email-mcp-custom help <command>"} {
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: <set> (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
}