| 1 | package doctor |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "os/user" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/internal/config" |
| 12 | ) |
| 13 | |
| 14 | func TestRedactHome(t *testing.T) { |
| 15 | home := t.TempDir() |
| 16 | t.Setenv("HOME", home) // os.UserHomeDir on unix |
| 17 | t.Setenv("USERPROFILE", home) // os.UserHomeDir on windows |
| 18 | sep := string(os.PathSeparator) |
| 19 | |
| 20 | if got := redactHome(home); got != "~" { |
| 21 | t.Fatalf("home itself: got %q, want ~", got) |
| 22 | } |
| 23 | under := filepath.Join(home, "projects", "x") |
| 24 | if got, want := redactHome(under), "~"+sep+"projects"+sep+"x"; got != want { |
| 25 | t.Fatalf("under home: got %q, want %q", got, want) |
| 26 | } |
| 27 | outside := filepath.Join(t.TempDir(), "elsewhere") // sibling temp, not under home |
| 28 | if got := redactHome(outside); got != outside { |
| 29 | t.Fatalf("outside home must be unchanged: got %q", got) |
| 30 | } |
| 31 | if got := redactHome(""); got != "" { |
| 32 | t.Fatalf("empty must stay empty: got %q", got) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | func TestCollectReportRedactsSecrets(t *testing.T) { |
| 37 | t.Setenv("REASONIX_TEST_SECRET", "sk-live-secret") |
| 38 | |
| 39 | cfg := config.Default() |
| 40 | cfg.DefaultModel = "custom" |
| 41 | cfg.Providers = []config.ProviderEntry{{ |
| 42 | Name: "custom", |
| 43 | Kind: "openai", |
| 44 | BaseURL: "https://api.example.com/v1?token=secret-query", |
| 45 | Model: "model-a", |
| 46 | APIKeyEnv: "REASONIX_TEST_SECRET", |
| 47 | }} |
| 48 | cfg.Plugins = []config.PluginEntry{{ |
| 49 | Name: "remote", |
| 50 | Type: "http", |
| 51 | URL: "https://mcp.example.com/path?api_key=secret-query", |
| 52 | Headers: map[string]string{"Authorization": "Bearer sk-live-secret"}, |
| 53 | }} |
| 54 | cfg.Network = config.NetworkConfig{ |
| 55 | ProxyMode: "custom", |
| 56 | Proxy: config.NetworkProxyConfig{ |
| 57 | Type: "socks5", |
| 58 | Server: "proxy.example.com", |
| 59 | Port: 1080, |
| 60 | Username: "proxy-user", |
| 61 | Password: "proxy-secret", |
| 62 | }, |
| 63 | } |
| 64 | |
| 65 | report := Collect(Options{Version: "test-version", Config: cfg}) |
| 66 | text := RenderText(report) |
| 67 | raw, err := json.Marshal(report) |
| 68 | if err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | combined := text + "\n" + string(raw) |
| 72 | |
| 73 | for _, secret := range []string{"sk-live-secret", "secret-query", "Authorization", "proxy-secret"} { |
| 74 | if strings.Contains(combined, secret) { |
| 75 | t.Fatalf("doctor report leaked %q:\n%s", secret, combined) |
| 76 | } |
| 77 | } |
| 78 | if !strings.Contains(combined, "api.example.com") || !strings.Contains(combined, "mcp.example.com") { |
| 79 | t.Fatalf("doctor report should keep useful host diagnostics:\n%s", combined) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | func TestCollectReportDoesNotRequireAPIKey(t *testing.T) { |
| 84 | t.Setenv("REASONIX_HOME", filepath.Join(t.TempDir(), "reasonix")) |
| 85 | t.Setenv("DEEPSEEK_API_KEY", "") |
| 86 | |
| 87 | cfg := config.Default() |
| 88 | report := Collect(Options{Version: "1.2.3", Config: cfg}) |
| 89 | text := RenderText(report) |
| 90 | |
| 91 | if report.Version != "1.2.3" { |
| 92 | t.Fatalf("version = %q, want 1.2.3", report.Version) |
| 93 | } |
| 94 | if len(report.Providers) == 0 { |
| 95 | t.Fatal("expected built-in providers in report") |
| 96 | } |
| 97 | if report.Providers[0].KeyPresent { |
| 98 | t.Fatal("provider key should be reported missing when env is empty") |
| 99 | } |
| 100 | if !strings.Contains(text, "reasonix 1.2.3 doctor") { |
| 101 | t.Fatalf("text report missing header:\n%s", text) |
| 102 | } |
| 103 | if !strings.Contains(text, "missing") { |
| 104 | t.Fatalf("text report should mention missing key state:\n%s", text) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestRenderTextSurfacesWarningsUpTop(t *testing.T) { |
| 109 | text := RenderText(Report{Warnings: []string{"config reasonix.toml: parse boom"}}) |
| 110 | w := strings.Index(text, "parse boom") |
| 111 | if w < 0 { |
| 112 | t.Fatalf("warning missing from report:\n%s", text) |
| 113 | } |
| 114 | if p := strings.Index(text, "\nproviders\n"); p >= 0 && w > p { |
| 115 | t.Fatalf("warning should appear before the providers section, not buried below:\n%s", text) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | func TestRenderTextFlagsUnavailableSandboxAsFailClosed(t *testing.T) { |
| 120 | inactive := RenderText(Report{Sandbox: SandboxReport{Bash: "enforce", Available: false}}) |
| 121 | if !strings.Contains(inactive, "bash execution is refused") { |
| 122 | t.Fatalf("enforce without an OS sandbox should report fail-closed bash behavior:\n%s", inactive) |
| 123 | } |
| 124 | if strings.Contains(inactive, "runs unconfined") { |
| 125 | t.Fatalf("enforce without an OS sandbox should not claim bash runs unconfined:\n%s", inactive) |
| 126 | } |
| 127 | |
| 128 | active := RenderText(Report{Sandbox: SandboxReport{Bash: "enforce", Available: true}}) |
| 129 | if strings.Contains(active, "bash execution is refused") { |
| 130 | t.Fatalf("enforce with an OS sandbox should not be flagged unavailable:\n%s", active) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // TestCollectFlagsIgnoredEnforceConfig pins the visibility contract for the |
| 135 | // platform force-off: when the config file says enforce but the effective mode |
| 136 | // resolves to off (Windows), doctor must say so in both the warnings list and |
| 137 | // the sandbox bash line instead of silently reporting "off". |
| 138 | func TestCollectFlagsIgnoredEnforceConfig(t *testing.T) { |
| 139 | t.Setenv("REASONIX_HOME", filepath.Join(t.TempDir(), "reasonix")) |
| 140 | |
| 141 | cfg := config.Default() |
| 142 | cfg.Sandbox.Bash = "enforce" |
| 143 | report := Collect(Options{Version: "test", Config: cfg}) |
| 144 | |
| 145 | ignored := cfg.BashMode() == "off" |
| 146 | if report.Sandbox.BashConfigIgnored != ignored { |
| 147 | t.Fatalf("BashConfigIgnored = %v, want %v (BashMode %q)", report.Sandbox.BashConfigIgnored, ignored, cfg.BashMode()) |
| 148 | } |
| 149 | |
| 150 | text := RenderText(Report{Sandbox: SandboxReport{Bash: "off", BashConfigIgnored: true}}) |
| 151 | if !strings.Contains(text, `config requests "enforce", ignored`) { |
| 152 | t.Fatalf("ignored enforce should be flagged on the bash line:\n%s", text) |
| 153 | } |
| 154 | plain := RenderText(Report{Sandbox: SandboxReport{Bash: "off"}}) |
| 155 | if strings.Contains(plain, "ignored") { |
| 156 | t.Fatalf("plain off must not claim the config was ignored:\n%s", plain) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | func TestHomeIsolationWarningDetectsMismatch(t *testing.T) { |
| 161 | // Prefer a synthetic mismatch without requiring root privileges. |
| 162 | acct, err := user.Current() |
| 163 | if err != nil || acct == nil || strings.TrimSpace(acct.HomeDir) == "" { |
| 164 | t.Skip("account home unavailable") |
| 165 | } |
| 166 | t.Setenv("REASONIX_HOME", "") |
| 167 | serviceHome := filepath.Join(t.TempDir(), "service-home") |
| 168 | t.Setenv("HOME", serviceHome) |
| 169 | t.Setenv("USERPROFILE", serviceHome) |
| 170 | got := homeIsolationWarning() |
| 171 | if got == "" { |
| 172 | t.Fatal("expected HOME mismatch warning") |
| 173 | } |
| 174 | if !strings.Contains(got, "REASONIX_HOME") { |
| 175 | t.Fatalf("warning = %q, want REASONIX_HOME guidance", got) |
| 176 | } |
| 177 | // Shareable output must not embed either absolute home path. |
| 178 | if strings.Contains(got, serviceHome) || strings.Contains(got, acct.HomeDir) { |
| 179 | t.Fatalf("warning leaked a home path: %q", got) |
| 180 | } |
| 181 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 182 | if got := homeIsolationWarning(); got != "" { |
| 183 | t.Fatalf("REASONIX_HOME set should silence warning, got %q", got) |
| 184 | } |
| 185 | } |
| 186 |