1261 lines
32 KiB
Go
1261 lines
32 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
|
|
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:", "doctor", "version"} {
|
|
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 doctor [--profile NAME]") {
|
|
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: resolved profile is complete",
|
|
"[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 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 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, "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
|
|
}
|