package cli import ( "bytes" "context" "errors" "fmt" "os" "path/filepath" "strings" "testing" "forge.lclr.dev/AI/mcp-framework/config" "forge.lclr.dev/AI/mcp-framework/manifest" "forge.lclr.dev/AI/mcp-framework/secretstore" ) type doctorProfile struct { BaseURL string `json:"base_url"` } func TestRunDoctorAggregatesCommonChecksAndExtras(t *testing.T) { tempHome := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tempHome) t.Setenv("HOME", tempHome) store := config.NewStore[doctorProfile]("doctor-test") path, err := store.ConfigPath() if err != nil { t.Fatalf("ConfigPath returned error: %v", err) } cfg := config.FileConfig[doctorProfile]{ Version: config.CurrentVersion, CurrentProfile: "default", Profiles: map[string]doctorProfile{ "default": {BaseURL: "https://api.example.com"}, }, } if err := store.Save(path, cfg); err != nil { t.Fatalf("Save returned error: %v", err) } manifestDir := t.TempDir() if err := os.WriteFile(filepath.Join(manifestDir, "mcp.toml"), []byte("[update]\nlatest_release_url = \"https://example.com/latest\"\n"), 0o600); err != nil { t.Fatalf("WriteFile manifest: %v", err) } secretFactory := func() (secretstore.Store, error) { return secretstore.Open(secretstore.Options{ BackendPolicy: secretstore.BackendEnvOnly, LookupEnv: func(name string) (string, bool) { if name == "api-token" { return "secret", true } return "", false }, }) } report := RunDoctor(context.Background(), DoctorOptions{ ConfigCheck: NewConfigCheck(store), SecretStoreCheck: SecretStoreAvailabilityCheck(secretFactory), RequiredSecrets: []DoctorSecret{ {Name: "api-token", Label: "API token"}, }, SecretStoreFactory: secretFactory, ManifestDir: manifestDir, ConnectivityCheck: func(context.Context) DoctorResult { return DoctorResult{Name: "connectivity", Status: DoctorStatusOK, Summary: "service is reachable"} }, ExtraChecks: []DoctorCheck{ func(context.Context) DoctorResult { return DoctorResult{Name: "custom", Status: DoctorStatusWarn, Summary: "non blocking drift"} }, }, }) summary := report.Summary() if summary.OK != 5 || summary.Warn != 1 || summary.Fail != 0 || summary.Total != 6 { t.Fatalf("summary = %#v", summary) } } func TestNewConfigCheckWarnsWhenConfigIsMissing(t *testing.T) { tempHome := t.TempDir() t.Setenv("XDG_CONFIG_HOME", tempHome) t.Setenv("HOME", tempHome) store := config.NewStore[doctorProfile]("doctor-test") result := NewConfigCheck(store)(context.Background()) if result.Status != DoctorStatusWarn { t.Fatalf("status = %q, want warn", result.Status) } if !strings.Contains(result.Detail, "config.json") { t.Fatalf("detail = %q, want config path", result.Detail) } } func TestRequiredSecretsCheckFailsWhenSecretIsMissing(t *testing.T) { check := RequiredSecretsCheck(func() (secretstore.Store, error) { return secretstore.Open(secretstore.Options{ BackendPolicy: secretstore.BackendEnvOnly, LookupEnv: func(name string) (string, bool) { return "", false }, }) }, []DoctorSecret{{Name: "API_TOKEN", Label: "API token"}}) result := check(context.Background()) if result.Status != DoctorStatusFail { t.Fatalf("status = %q, want fail", result.Status) } if !strings.Contains(result.Detail, "API_TOKEN") { t.Fatalf("detail = %q, want secret name", result.Detail) } } func TestManifestCheckUsesValidator(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "mcp.toml"), []byte("[update]\nlatest_release_url = \"https://example.com/latest\"\n"), 0o600); err != nil { t.Fatalf("WriteFile manifest: %v", err) } result := ManifestCheck(dir, func(_ manifest.File, path string) []string { _ = path return []string{"missing business section"} })(context.Background()) if result.Status != DoctorStatusFail { t.Fatalf("status = %q, want fail", result.Status) } } func TestRunDoctorUsesCustomManifestCheckWhenProvided(t *testing.T) { report := RunDoctor(context.Background(), DoctorOptions{ ManifestDir: t.TempDir(), ManifestCheck: func(context.Context) DoctorResult { return DoctorResult{ Name: "manifest", Status: DoctorStatusOK, Summary: "manifest is embedded", Detail: "embedded:mcp.toml", } }, }) if len(report.Results) != 1 { t.Fatalf("result count = %d, want 1", len(report.Results)) } result := report.Results[0] if result.Summary != "manifest is embedded" { t.Fatalf("summary = %q", result.Summary) } if result.Detail != "embedded:mcp.toml" { t.Fatalf("detail = %q", result.Detail) } } func TestRenderDoctorReportFormatsStatusesAndSummary(t *testing.T) { var out bytes.Buffer report := DoctorReport{ Results: []DoctorResult{ {Name: "config", Status: DoctorStatusOK, Summary: "config file is readable", Detail: "/tmp/config.json"}, {Name: "custom", Status: DoctorStatusWarn, Summary: "optional check skipped"}, {Name: "manifest", Status: DoctorStatusFail, Summary: "manifest is invalid", Detail: "parse error"}, }, } if err := RenderDoctorReport(&out, report); err != nil { t.Fatalf("RenderDoctorReport returned error: %v", err) } text := out.String() for _, needle := range []string{ "[OK] config: config file is readable", "[WARN] custom: optional check skipped", "[FAIL] manifest: manifest is invalid", "Summary: 1 ok, 1 warning(s), 1 failure(s), 3 total", } { if !strings.Contains(text, needle) { t.Fatalf("output = %q, want substring %q", text, needle) } } } func TestSecretStoreAvailabilityCheckFailsWhenFactoryFails(t *testing.T) { result := SecretStoreAvailabilityCheck(func() (secretstore.Store, error) { return nil, errors.New("backend missing") })(context.Background()) if result.Status != DoctorStatusFail { t.Fatalf("status = %q, want fail", result.Status) } if !strings.Contains(result.Detail, "backend missing") { t.Fatalf("detail = %q", result.Detail) } } func TestBitwardenReadyCheckMapsTypedErrorsToActionableDiagnostics(t *testing.T) { prev := checkBitwardenReady t.Cleanup(func() { checkBitwardenReady = prev }) t.Run("not logged in", func(t *testing.T) { checkBitwardenReady = func(options secretstore.Options) error { return fmt.Errorf("%w: run `bw login`", secretstore.ErrBWNotLoggedIn) } result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background()) if result.Status != DoctorStatusFail { t.Fatalf("status = %q, want fail", result.Status) } if result.Summary != "bitwarden login is required" { t.Fatalf("summary = %q", result.Summary) } if !strings.Contains(result.Detail, "bw login") { t.Fatalf("detail = %q, want login remediation", result.Detail) } }) t.Run("locked", func(t *testing.T) { checkBitwardenReady = func(options secretstore.Options) error { return fmt.Errorf("%w: run `bw unlock --raw`", secretstore.ErrBWLocked) } result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background()) if result.Status != DoctorStatusFail { t.Fatalf("status = %q, want fail", result.Status) } if result.Summary != "bitwarden vault is locked or BW_SESSION is missing" { t.Fatalf("summary = %q", result.Summary) } if !strings.Contains(result.Detail, "bw unlock --raw") { t.Fatalf("detail = %q, want unlock remediation", result.Detail) } }) t.Run("ready", func(t *testing.T) { checkBitwardenReady = func(options secretstore.Options) error { return nil } result := BitwardenReadyCheck(BitwardenDoctorOptions{})(context.Background()) if result.Status != DoctorStatusOK { t.Fatalf("status = %q, want ok", result.Status) } if result.Summary != "bitwarden CLI is ready" { t.Fatalf("summary = %q", result.Summary) } }) } func TestRunDoctorAutoInjectsBitwardenCheckForBitwardenPolicy(t *testing.T) { prev := checkBitwardenReady t.Cleanup(func() { checkBitwardenReady = prev }) lookupCalled := false checkBitwardenReady = func(options secretstore.Options) error { if options.Shell != "fish" { t.Fatalf("Shell = %q, want fish", options.Shell) } if options.BitwardenCommand != "bw" { t.Fatalf("BitwardenCommand = %q, want bw", options.BitwardenCommand) } if options.LookupEnv == nil { t.Fatal("LookupEnv should be forwarded") } _, _ = options.LookupEnv("BW_SESSION") return fmt.Errorf("%w: run unlock", secretstore.ErrBWLocked) } report := RunDoctor(context.Background(), DoctorOptions{ SecretBackendPolicy: secretstore.BackendBitwardenCLI, BitwardenOptions: BitwardenDoctorOptions{ Command: "bw", Shell: "fish", LookupEnv: func(name string) (string, bool) { lookupCalled = true return "", false }, }, ManifestCheck: func(context.Context) DoctorResult { return DoctorResult{Name: "manifest", Status: DoctorStatusOK, Summary: "manifest ok"} }, }) if !lookupCalled { t.Fatal("LookupEnv should be used by auto bitwarden check") } var found *DoctorResult for i := range report.Results { if report.Results[i].Name == "bitwarden" { found = &report.Results[i] break } } if found == nil { t.Fatalf("report results = %#v, want auto bitwarden check", report.Results) } if found.Status != DoctorStatusFail { t.Fatalf("bitwarden status = %q, want fail", found.Status) } } func TestRunDoctorCanDisableAutoBitwardenCheck(t *testing.T) { prev := checkBitwardenReady t.Cleanup(func() { checkBitwardenReady = prev }) checkBitwardenReady = func(options secretstore.Options) error { t.Fatal("checkBitwardenReady should not be called when auto check is disabled") return nil } report := RunDoctor(context.Background(), DoctorOptions{ DisableAutoBitwardenCheck: true, SecretBackendPolicy: secretstore.BackendBitwardenCLI, ManifestCheck: func(context.Context) DoctorResult { return DoctorResult{Name: "manifest", Status: DoctorStatusOK, Summary: "manifest ok"} }, }) if len(report.Results) != 1 { t.Fatalf("result count = %d, want 1", len(report.Results)) } if report.Results[0].Name != "manifest" { t.Fatalf("result name = %q, want manifest", report.Results[0].Name) } } func TestRequiredResolvedFieldsCheckReportsSources(t *testing.T) { check := RequiredResolvedFieldsCheck(ResolveOptions{ Fields: []FieldSpec{ {Name: "base_url", Required: true, ConfigKey: "base_url"}, { Name: "api_token", Required: true, EnvKey: "MY_API_TOKEN", SecretKey: "my-api-token", Sources: []ValueSource{SourceEnv, SourceSecret}, }, }, Lookup: ResolveLookup(ResolveLookupOptions{ Env: MapLookup(map[string]string{"MY_API_TOKEN": "env-token"}), Config: ConfigMap(map[string]string{"base_url": "https://api.example.com"}), Secret: MapLookup(map[string]string{"my-api-token": "secret-token"}), }), }) result := check(context.Background()) if result.Status != DoctorStatusOK { t.Fatalf("status = %q, want ok", result.Status) } for _, needle := range []string{"base_url=config", "api_token=env"} { if !strings.Contains(result.Detail, needle) { t.Fatalf("detail = %q, want substring %q", result.Detail, needle) } } } func TestRequiredResolvedFieldsCheckUsesSecretAsFallback(t *testing.T) { check := RequiredResolvedFieldsCheck(ResolveOptions{ Fields: []FieldSpec{ { Name: "api_token", Required: true, EnvKey: "MY_API_TOKEN", SecretKey: "my-api-token", Sources: []ValueSource{SourceEnv, SourceSecret}, }, }, Lookup: ResolveLookup(ResolveLookupOptions{ Env: MapLookup(map[string]string{}), Secret: MapLookup(map[string]string{"my-api-token": "secret-token"}), }), }) result := check(context.Background()) if result.Status != DoctorStatusOK { t.Fatalf("status = %q, want ok", result.Status) } if !strings.Contains(result.Detail, "api_token=secret") { t.Fatalf("detail = %q, want secret provenance", result.Detail) } } func TestRequiredResolvedFieldsCheckFailsWithMissingRequiredField(t *testing.T) { check := RequiredResolvedFieldsCheck(ResolveOptions{ Fields: []FieldSpec{ {Name: "base_url", Required: true}, }, Lookup: ResolveLookup(ResolveLookupOptions{ Env: MapLookup(map[string]string{}), }), }) result := check(context.Background()) if result.Status != DoctorStatusFail { t.Fatalf("status = %q, want fail", result.Status) } if result.Summary != "required profile values are missing" { t.Fatalf("summary = %q", result.Summary) } if result.Detail != "base_url" { t.Fatalf("detail = %q, want base_url", result.Detail) } } func TestRequiredResolvedFieldsCheckFormatsLookupErrors(t *testing.T) { lookupErr := errors.New("vault unavailable") check := RequiredResolvedFieldsCheck(ResolveOptions{ Fields: []FieldSpec{ {Name: "api_token", Required: true, Sources: []ValueSource{SourceSecret}}, }, Lookup: func(source ValueSource, key string) (string, bool, error) { if source == SourceSecret { return "", false, lookupErr } return "", false, nil }, }) result := check(context.Background()) if result.Status != DoctorStatusFail { t.Fatalf("status = %q, want fail", result.Status) } if result.Summary != "cannot resolve profile value \"api_token\"" { t.Fatalf("summary = %q", result.Summary) } for _, needle := range []string{"api_token", "source \"secret\"", "key \"api_token\"", "vault unavailable"} { if !strings.Contains(result.Detail, needle) { t.Fatalf("detail = %q, want substring %q", result.Detail, needle) } } } func TestFormatResolveFieldsErrorWithNilError(t *testing.T) { if got := FormatResolveFieldsError(nil); got != "" { t.Fatalf("FormatResolveFieldsError(nil) = %q, want empty string", got) } }