返回 DeepSeek-Reasonix
probe_test.go
根目录 / internal / environment / probe_test.go
1 package environment
2
3 import (
4 "context"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "testing"
12 "time"
13
14 "reasonix/internal/secrets"
15 )
16
17 func TestFormatSectionSortsAndRedacts(t *testing.T) {
18 home, err := os.UserHomeDir()
19 if err != nil {
20 t.Fatalf("home: %v", err)
21 }
22 section := FormatSection([]ProbeResult{
23 {Binary: "python3", Found: true, Output: "Python 3.12.0"},
24 {Binary: "go", Found: true, Output: "go version go1.24 darwin/arm64"},
25 {Binary: "docker", Error: "not found"},
26 }, "darwin/arm64", filepath.Join(home, "bin", "bash"), map[string]string{
27 "python3": filepath.Join(home, ".pyenv", "shims", "python3"),
28 "go": "/opt/homebrew/bin/go",
29 })
30
31 for _, want := range []string{
32 "## Environment",
33 "- OS: darwin/arm64",
34 "- Shell: ~/bin/bash",
35 "Configured tools:\n- go: /opt/homebrew/bin/go\n- python3: ~/.pyenv/shims/python3",
36 "Detected tools:\n- go: go version go1.24 darwin/arm64\n- python3: Python 3.12.0",
37 "Not found or unavailable:\n- docker: not found",
38 } {
39 if !strings.Contains(section, want) {
40 t.Fatalf("section missing %q:\n%s", want, section)
41 }
42 }
43 }
44
45 func TestRunProbesReportsMissingCommand(t *testing.T) {
46 results := RunProbes(context.Background(), []string{"__reasonix_missing_probe__ --version"})
47 if len(results) != 1 {
48 t.Fatalf("results len = %d, want 1", len(results))
49 }
50 if results[0].Found {
51 t.Fatalf("missing command marked found: %+v", results[0])
52 }
53 if results[0].Error != "not found" {
54 t.Fatalf("Error = %q, want not found", results[0].Error)
55 }
56 }
57
58 func TestRunProbesUsesOverridePathAndFirstLine(t *testing.T) {
59 dir := t.TempDir()
60 toolPath := filepath.Join(dir, "mytool")
61 toolPath = writeProbeTool(t, toolPath, "custom version\nignored")
62
63 results := RunProbesWithOverrides(context.Background(), []string{"mytool --version"}, map[string]string{"mytool": toolPath})
64 if len(results) != 1 {
65 t.Fatalf("results len = %d, want 1", len(results))
66 }
67 if !results[0].Found {
68 t.Fatalf("override command not found: %+v", results[0])
69 }
70 if results[0].Output != "custom version" {
71 t.Fatalf("Output = %q, want first line", results[0].Output)
72 }
73 }
74
75 func TestRunProbesParsesQuotedStaticArgs(t *testing.T) {
76 resetProbeCacheForTest(t, time.Unix(25, 0))
77 dir := t.TempDir()
78 toolPath := filepath.Join(dir, "quotedtool")
79 toolPath = writeProbeTool(t, toolPath, "quoted version")
80
81 results := RunProbesWithOverrides(context.Background(), []string{`quotedtool "--version with spaces"`}, map[string]string{"quotedtool": toolPath})
82 if len(results) != 1 {
83 t.Fatalf("results len = %d, want 1", len(results))
84 }
85 if !results[0].Found || results[0].Output != "quoted version" {
86 t.Fatalf("quoted probe result = %+v", results[0])
87 }
88 }
89
90 func TestRunProbesAllowsStaticEnvAssignment(t *testing.T) {
91 resetProbeCacheForTest(t, time.Unix(35, 0))
92 dir := t.TempDir()
93 toolPath := filepath.Join(dir, "envtool")
94 toolPath = writeEnvProbeTool(t, toolPath)
95
96 results := RunProbesWithOverrides(context.Background(), []string{`REASONIX_PROBE_ENV=ok envtool --version`}, map[string]string{"envtool": toolPath})
97 if len(results) != 1 {
98 t.Fatalf("results len = %d, want 1", len(results))
99 }
100 if !results[0].Found || results[0].Output != "ok" {
101 t.Fatalf("env probe result = %+v", results[0])
102 }
103 }
104
105 func TestRunProbesAllowsStaticStderrMerge(t *testing.T) {
106 resetProbeCacheForTest(t, time.Unix(40, 0))
107 dir := t.TempDir()
108 toolPath := filepath.Join(dir, "stderrtool")
109 toolPath = writeStderrProbeTool(t, toolPath, "stderr version")
110
111 results := RunProbesWithOverrides(context.Background(), []string{`stderrtool --version 2>&1`}, map[string]string{"stderrtool": toolPath})
112 if len(results) != 1 {
113 t.Fatalf("results len = %d, want 1", len(results))
114 }
115 if !results[0].Found || results[0].Output != "stderr version" {
116 t.Fatalf("stderr merge probe result = %+v", results[0])
117 }
118 }
119
120 func TestRunProbesRejectsDeniedOverridePath(t *testing.T) {
121 resetProbeCacheForTest(t, time.Unix(50, 0))
122 dir := t.TempDir()
123 toolPath := filepath.Join(dir, "deniedtool")
124 toolPath = writeProbeTool(t, toolPath, "should not run")
125
126 results := RunProbesWithOptions(context.Background(), []string{"deniedtool --version"}, ProbeOptions{
127 Overrides: map[string]string{"deniedtool": toolPath},
128 DenyRoots: []string{dir},
129 })
130 if len(results) != 1 {
131 t.Fatalf("results len = %d, want 1", len(results))
132 }
133 if results[0].Found || results[0].Error != "not trusted" {
134 t.Fatalf("denied override result = %+v, want not trusted", results[0])
135 }
136 }
137
138 func TestRunProbesRejectsDeniedPathHit(t *testing.T) {
139 resetProbeCacheForTest(t, time.Unix(75, 0))
140 dir := t.TempDir()
141 writeProbeTool(t, filepath.Join(dir, "pathtool"), "should not run")
142 t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
143
144 results := RunProbesWithOptions(context.Background(), []string{"pathtool --version"}, ProbeOptions{
145 DenyRoots: []string{dir},
146 })
147 if len(results) != 1 {
148 t.Fatalf("results len = %d, want 1", len(results))
149 }
150 if results[0].Found || results[0].Error != "not trusted" {
151 t.Fatalf("denied PATH result = %+v, want not trusted", results[0])
152 }
153 }
154
155 func TestRunProbesReportsTimeout(t *testing.T) {
156 setProbeTimeoutForTest(t, 200*time.Millisecond)
157 dir := t.TempDir()
158 toolPath := filepath.Join(dir, "slowtool")
159 body := "#!/bin/sh\nsleep 3\n"
160 if runtime.GOOS == "windows" {
161 toolPath += ".bat"
162 body = "@ping 127.0.0.1 -n 4 > nul\r\n"
163 }
164 if err := os.WriteFile(toolPath, []byte(body), 0o755); err != nil {
165 t.Fatalf("write tool: %v", err)
166 }
167
168 results := RunProbesWithOverrides(context.Background(), []string{"slowtool --version"}, map[string]string{"slowtool": toolPath})
169 if len(results) != 1 {
170 t.Fatalf("results len = %d, want 1", len(results))
171 }
172 if results[0].Found {
173 t.Fatalf("timeout command marked found: %+v", results[0])
174 }
175 if results[0].Error != "timeout" {
176 t.Fatalf("Error = %q, want timeout", results[0].Error)
177 }
178 }
179
180 func TestPrepareProbeCommandSetsCancellationBudget(t *testing.T) {
181 cmd := exec.Command("reasonix-test-probe")
182 prepareProbeCommand(cmd)
183 if cmd.Cancel == nil {
184 t.Fatal("probe command must install a cancellation hook")
185 }
186 if cmd.WaitDelay != probeWaitDelay {
187 t.Fatalf("WaitDelay = %v, want %v", cmd.WaitDelay, probeWaitDelay)
188 }
189 if runtime.GOOS == "windows" && cmd.SysProcAttr == nil {
190 t.Fatal("probe command must hide console windows on Windows")
191 }
192 }
193
194 func TestRunProbesCachesByFingerprint(t *testing.T) {
195 resetProbeCacheForTest(t, time.Unix(100, 0))
196 dir := t.TempDir()
197 toolPath := filepath.Join(dir, "cachedtool")
198 toolPath = writeProbeTool(t, toolPath, "version one")
199
200 results := RunProbesWithOverrides(context.Background(), []string{"cachedtool --version"}, map[string]string{"cachedtool": toolPath})
201 if got := results[0].Output; got != "version one" {
202 t.Fatalf("first Output = %q, want version one", got)
203 }
204 results[0].Output = "mutated"
205 toolPath = writeProbeTool(t, toolPath, "version two")
206
207 results = RunProbesWithOverrides(context.Background(), []string{"cachedtool --version"}, map[string]string{"cachedtool": toolPath})
208 if got := results[0].Output; got != "version one" {
209 t.Fatalf("cached Output = %q, want version one", got)
210 }
211 }
212
213 func TestRunProbesCacheExpires(t *testing.T) {
214 now := time.Unix(200, 0)
215 resetProbeCacheForTest(t, now)
216 dir := t.TempDir()
217 toolPath := filepath.Join(dir, "expiringtool")
218 toolPath = writeProbeTool(t, toolPath, "version one")
219
220 results := RunProbesWithOverrides(context.Background(), []string{"expiringtool --version"}, map[string]string{"expiringtool": toolPath})
221 if got := results[0].Output; got != "version one" {
222 t.Fatalf("first Output = %q, want version one", got)
223 }
224 toolPath = writeProbeTool(t, toolPath, "version two")
225 setProbeNowForTest(now.Add(probeCacheTTL + time.Second))
226
227 results = RunProbesWithOverrides(context.Background(), []string{"expiringtool --version"}, map[string]string{"expiringtool": toolPath})
228 if got := results[0].Output; got != "version two" {
229 t.Fatalf("expired Output = %q, want version two", got)
230 }
231 }
232
233 func TestRunProbesCacheSeparatesOverrides(t *testing.T) {
234 resetProbeCacheForTest(t, time.Unix(300, 0))
235 dir := t.TempDir()
236 toolOne := filepath.Join(dir, "override-one")
237 toolTwo := filepath.Join(dir, "override-two")
238 toolOne = writeProbeTool(t, toolOne, "version one")
239 toolTwo = writeProbeTool(t, toolTwo, "version two")
240
241 results := RunProbesWithOverrides(context.Background(), []string{"overridetool --version"}, map[string]string{"overridetool": toolOne})
242 if got := results[0].Output; got != "version one" {
243 t.Fatalf("first override Output = %q, want version one", got)
244 }
245 results = RunProbesWithOverrides(context.Background(), []string{"overridetool --version"}, map[string]string{"overridetool": toolTwo})
246 if got := results[0].Output; got != "version two" {
247 t.Fatalf("second override Output = %q, want version two", got)
248 }
249 }
250
251 func TestFormatSectionLimitsToolOutput(t *testing.T) {
252 overrides := map[string]string{}
253 var results []ProbeResult
254 for i := 0; i < maxRenderedTools+2; i++ {
255 name := fmt.Sprintf("tool%02d", i)
256 overrides[name] = "/bin/" + name
257 results = append(results, ProbeResult{Binary: name, Found: true, Output: "ok"})
258 results = append(results, ProbeResult{Binary: "missing" + name, Error: "not found"})
259 }
260
261 section := FormatSection(results, "test/os", "", overrides)
262 for _, want := range []string{
263 "- ... 2 more configured tools omitted",
264 "- ... 2 more detected tools omitted",
265 "- ... 2 more unavailable tools omitted",
266 } {
267 if !strings.Contains(section, want) {
268 t.Fatalf("section missing %q:\n%s", want, section)
269 }
270 }
271 }
272
273 func writeProbeTool(t *testing.T, path, output string) string {
274 t.Helper()
275 setProbeTimeoutForTest(t, 10*time.Second)
276 body := "#!/bin/sh\nprintf '%s\\n'\n"
277 body = fmt.Sprintf(body, strings.ReplaceAll(output, "'", "'\\''"))
278 if runtime.GOOS == "windows" {
279 if !strings.HasSuffix(path, ".bat") {
280 path += ".bat"
281 }
282 body = "@echo " + strings.ReplaceAll(output, "\n", "\r\n@echo ") + "\r\n"
283 }
284 if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
285 t.Fatalf("write tool: %v", err)
286 }
287 return path
288 }
289
290 func writeEnvProbeTool(t *testing.T, path string) string {
291 t.Helper()
292 setProbeTimeoutForTest(t, 10*time.Second)
293 body := "#!/bin/sh\nprintf '%s\\n' \"$REASONIX_PROBE_ENV\"\n"
294 if runtime.GOOS == "windows" {
295 if !strings.HasSuffix(path, ".bat") {
296 path += ".bat"
297 }
298 body = "@echo %REASONIX_PROBE_ENV%\r\n"
299 }
300 if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
301 t.Fatalf("write env tool: %v", err)
302 }
303 return path
304 }
305
306 func writeStderrProbeTool(t *testing.T, path, output string) string {
307 t.Helper()
308 setProbeTimeoutForTest(t, 10*time.Second)
309 body := "#!/bin/sh\nprintf '%s\\n' >&2\n"
310 body = fmt.Sprintf(body, strings.ReplaceAll(output, "'", "'\\''"))
311 if runtime.GOOS == "windows" {
312 if !strings.HasSuffix(path, ".bat") {
313 path += ".bat"
314 }
315 body = "@echo " + strings.ReplaceAll(output, "\n", "\r\n@echo ") + " 1>&2\r\n"
316 }
317 if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
318 t.Fatalf("write stderr tool: %v", err)
319 }
320 return path
321 }
322
323 func resetProbeCacheForTest(t *testing.T, now time.Time) {
324 t.Helper()
325 setProbeNowForTest(now)
326 probeCacheMu.Lock()
327 probeCache = map[string]probeCacheEntry{}
328 probeInflightCalls = map[string]*probeInflight{}
329 probeCacheMu.Unlock()
330 t.Cleanup(func() {
331 probeCacheMu.Lock()
332 probeCache = map[string]probeCacheEntry{}
333 probeInflightCalls = map[string]*probeInflight{}
334 probeNow = time.Now
335 probeTimeout = ProbeTimeout
336 probeCacheMu.Unlock()
337 })
338 }
339
340 func setProbeNowForTest(now time.Time) {
341 probeCacheMu.Lock()
342 probeNow = func() time.Time { return now }
343 probeCacheMu.Unlock()
344 }
345
346 func setProbeTimeoutForTest(t *testing.T, timeout time.Duration) {
347 t.Helper()
348 probeCacheMu.Lock()
349 probeTimeout = timeout
350 probeCacheMu.Unlock()
351 t.Cleanup(func() {
352 probeCacheMu.Lock()
353 probeTimeout = ProbeTimeout
354 probeCacheMu.Unlock()
355 })
356 }
357
358 func TestRunProbesFilterSubprocessEnv(t *testing.T) {
359 if runtime.GOOS == "windows" {
360 t.Skip("POSIX shell probe tool")
361 }
362 resetProbeCacheForTest(t, time.Unix(90, 0))
363 setProbeTimeoutForTest(t, 10*time.Second)
364 dir := t.TempDir()
365 toolPath := filepath.Join(dir, "envtool")
366 body := "#!/bin/sh\nprintf 'tok=%s' \"${REASONIX_TEST_SECRET_TOKEN:-none}\"\n"
367 if err := os.WriteFile(toolPath, []byte(body), 0o755); err != nil {
368 t.Fatal(err)
369 }
370 t.Setenv("REASONIX_TEST_SECRET_TOKEN", "ghp_abcdefghijklmnopqrstuvwxyz")
371 secrets.SetFilterSubprocessEnv(true)
372 t.Cleanup(func() { secrets.SetFilterSubprocessEnv(false) })
373
374 results := RunProbesWithOverrides(context.Background(), []string{"envtool --version"}, map[string]string{"envtool": toolPath})
375 if len(results) != 1 || !results[0].Found {
376 t.Fatalf("probe result = %+v", results)
377 }
378 // Probes declaring no extra env of their own must still get the filtered
379 // environment, not inherit the full one.
380 if results[0].Output != "tok=none" {
381 t.Fatalf("probe leaked filtered env: output = %q", results[0].Output)
382 }
383 }
384
384 lines GO