384 lines
13 KiB
Go
384 lines
13 KiB
Go
package environment
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"reasonix/internal/secrets"
|
|
)
|
|
|
|
func TestFormatSectionSortsAndRedacts(t *testing.T) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
t.Fatalf("home: %v", err)
|
|
}
|
|
section := FormatSection([]ProbeResult{
|
|
{Binary: "python3", Found: true, Output: "Python 3.12.0"},
|
|
{Binary: "go", Found: true, Output: "go version go1.24 darwin/arm64"},
|
|
{Binary: "docker", Error: "not found"},
|
|
}, "darwin/arm64", filepath.Join(home, "bin", "bash"), map[string]string{
|
|
"python3": filepath.Join(home, ".pyenv", "shims", "python3"),
|
|
"go": "/opt/homebrew/bin/go",
|
|
})
|
|
|
|
for _, want := range []string{
|
|
"## Environment",
|
|
"- OS: darwin/arm64",
|
|
"- Shell: ~/bin/bash",
|
|
"Configured tools:\n- go: /opt/homebrew/bin/go\n- python3: ~/.pyenv/shims/python3",
|
|
"Detected tools:\n- go: go version go1.24 darwin/arm64\n- python3: Python 3.12.0",
|
|
"Not found or unavailable:\n- docker: not found",
|
|
} {
|
|
if !strings.Contains(section, want) {
|
|
t.Fatalf("section missing %q:\n%s", want, section)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunProbesReportsMissingCommand(t *testing.T) {
|
|
results := RunProbes(context.Background(), []string{"__reasonix_missing_probe__ --version"})
|
|
if len(results) != 1 {
|
|
t.Fatalf("results len = %d, want 1", len(results))
|
|
}
|
|
if results[0].Found {
|
|
t.Fatalf("missing command marked found: %+v", results[0])
|
|
}
|
|
if results[0].Error != "not found" {
|
|
t.Fatalf("Error = %q, want not found", results[0].Error)
|
|
}
|
|
}
|
|
|
|
func TestRunProbesUsesOverridePathAndFirstLine(t *testing.T) {
|
|
dir := t.TempDir()
|
|
toolPath := filepath.Join(dir, "mytool")
|
|
toolPath = writeProbeTool(t, toolPath, "custom version\nignored")
|
|
|
|
results := RunProbesWithOverrides(context.Background(), []string{"mytool --version"}, map[string]string{"mytool": toolPath})
|
|
if len(results) != 1 {
|
|
t.Fatalf("results len = %d, want 1", len(results))
|
|
}
|
|
if !results[0].Found {
|
|
t.Fatalf("override command not found: %+v", results[0])
|
|
}
|
|
if results[0].Output != "custom version" {
|
|
t.Fatalf("Output = %q, want first line", results[0].Output)
|
|
}
|
|
}
|
|
|
|
func TestRunProbesParsesQuotedStaticArgs(t *testing.T) {
|
|
resetProbeCacheForTest(t, time.Unix(25, 0))
|
|
dir := t.TempDir()
|
|
toolPath := filepath.Join(dir, "quotedtool")
|
|
toolPath = writeProbeTool(t, toolPath, "quoted version")
|
|
|
|
results := RunProbesWithOverrides(context.Background(), []string{`quotedtool "--version with spaces"`}, map[string]string{"quotedtool": toolPath})
|
|
if len(results) != 1 {
|
|
t.Fatalf("results len = %d, want 1", len(results))
|
|
}
|
|
if !results[0].Found || results[0].Output != "quoted version" {
|
|
t.Fatalf("quoted probe result = %+v", results[0])
|
|
}
|
|
}
|
|
|
|
func TestRunProbesAllowsStaticEnvAssignment(t *testing.T) {
|
|
resetProbeCacheForTest(t, time.Unix(35, 0))
|
|
dir := t.TempDir()
|
|
toolPath := filepath.Join(dir, "envtool")
|
|
toolPath = writeEnvProbeTool(t, toolPath)
|
|
|
|
results := RunProbesWithOverrides(context.Background(), []string{`REASONIX_PROBE_ENV=ok envtool --version`}, map[string]string{"envtool": toolPath})
|
|
if len(results) != 1 {
|
|
t.Fatalf("results len = %d, want 1", len(results))
|
|
}
|
|
if !results[0].Found || results[0].Output != "ok" {
|
|
t.Fatalf("env probe result = %+v", results[0])
|
|
}
|
|
}
|
|
|
|
func TestRunProbesAllowsStaticStderrMerge(t *testing.T) {
|
|
resetProbeCacheForTest(t, time.Unix(40, 0))
|
|
dir := t.TempDir()
|
|
toolPath := filepath.Join(dir, "stderrtool")
|
|
toolPath = writeStderrProbeTool(t, toolPath, "stderr version")
|
|
|
|
results := RunProbesWithOverrides(context.Background(), []string{`stderrtool --version 2>&1`}, map[string]string{"stderrtool": toolPath})
|
|
if len(results) != 1 {
|
|
t.Fatalf("results len = %d, want 1", len(results))
|
|
}
|
|
if !results[0].Found || results[0].Output != "stderr version" {
|
|
t.Fatalf("stderr merge probe result = %+v", results[0])
|
|
}
|
|
}
|
|
|
|
func TestRunProbesRejectsDeniedOverridePath(t *testing.T) {
|
|
resetProbeCacheForTest(t, time.Unix(50, 0))
|
|
dir := t.TempDir()
|
|
toolPath := filepath.Join(dir, "deniedtool")
|
|
toolPath = writeProbeTool(t, toolPath, "should not run")
|
|
|
|
results := RunProbesWithOptions(context.Background(), []string{"deniedtool --version"}, ProbeOptions{
|
|
Overrides: map[string]string{"deniedtool": toolPath},
|
|
DenyRoots: []string{dir},
|
|
})
|
|
if len(results) != 1 {
|
|
t.Fatalf("results len = %d, want 1", len(results))
|
|
}
|
|
if results[0].Found || results[0].Error != "not trusted" {
|
|
t.Fatalf("denied override result = %+v, want not trusted", results[0])
|
|
}
|
|
}
|
|
|
|
func TestRunProbesRejectsDeniedPathHit(t *testing.T) {
|
|
resetProbeCacheForTest(t, time.Unix(75, 0))
|
|
dir := t.TempDir()
|
|
writeProbeTool(t, filepath.Join(dir, "pathtool"), "should not run")
|
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
|
|
results := RunProbesWithOptions(context.Background(), []string{"pathtool --version"}, ProbeOptions{
|
|
DenyRoots: []string{dir},
|
|
})
|
|
if len(results) != 1 {
|
|
t.Fatalf("results len = %d, want 1", len(results))
|
|
}
|
|
if results[0].Found || results[0].Error != "not trusted" {
|
|
t.Fatalf("denied PATH result = %+v, want not trusted", results[0])
|
|
}
|
|
}
|
|
|
|
func TestRunProbesReportsTimeout(t *testing.T) {
|
|
setProbeTimeoutForTest(t, 200*time.Millisecond)
|
|
dir := t.TempDir()
|
|
toolPath := filepath.Join(dir, "slowtool")
|
|
body := "#!/bin/sh\nsleep 3\n"
|
|
if runtime.GOOS == "windows" {
|
|
toolPath += ".bat"
|
|
body = "@ping 127.0.0.1 -n 4 > nul\r\n"
|
|
}
|
|
if err := os.WriteFile(toolPath, []byte(body), 0o755); err != nil {
|
|
t.Fatalf("write tool: %v", err)
|
|
}
|
|
|
|
results := RunProbesWithOverrides(context.Background(), []string{"slowtool --version"}, map[string]string{"slowtool": toolPath})
|
|
if len(results) != 1 {
|
|
t.Fatalf("results len = %d, want 1", len(results))
|
|
}
|
|
if results[0].Found {
|
|
t.Fatalf("timeout command marked found: %+v", results[0])
|
|
}
|
|
if results[0].Error != "timeout" {
|
|
t.Fatalf("Error = %q, want timeout", results[0].Error)
|
|
}
|
|
}
|
|
|
|
func TestPrepareProbeCommandSetsCancellationBudget(t *testing.T) {
|
|
cmd := exec.Command("reasonix-test-probe")
|
|
prepareProbeCommand(cmd)
|
|
if cmd.Cancel == nil {
|
|
t.Fatal("probe command must install a cancellation hook")
|
|
}
|
|
if cmd.WaitDelay != probeWaitDelay {
|
|
t.Fatalf("WaitDelay = %v, want %v", cmd.WaitDelay, probeWaitDelay)
|
|
}
|
|
if runtime.GOOS == "windows" && cmd.SysProcAttr == nil {
|
|
t.Fatal("probe command must hide console windows on Windows")
|
|
}
|
|
}
|
|
|
|
func TestRunProbesCachesByFingerprint(t *testing.T) {
|
|
resetProbeCacheForTest(t, time.Unix(100, 0))
|
|
dir := t.TempDir()
|
|
toolPath := filepath.Join(dir, "cachedtool")
|
|
toolPath = writeProbeTool(t, toolPath, "version one")
|
|
|
|
results := RunProbesWithOverrides(context.Background(), []string{"cachedtool --version"}, map[string]string{"cachedtool": toolPath})
|
|
if got := results[0].Output; got != "version one" {
|
|
t.Fatalf("first Output = %q, want version one", got)
|
|
}
|
|
results[0].Output = "mutated"
|
|
toolPath = writeProbeTool(t, toolPath, "version two")
|
|
|
|
results = RunProbesWithOverrides(context.Background(), []string{"cachedtool --version"}, map[string]string{"cachedtool": toolPath})
|
|
if got := results[0].Output; got != "version one" {
|
|
t.Fatalf("cached Output = %q, want version one", got)
|
|
}
|
|
}
|
|
|
|
func TestRunProbesCacheExpires(t *testing.T) {
|
|
now := time.Unix(200, 0)
|
|
resetProbeCacheForTest(t, now)
|
|
dir := t.TempDir()
|
|
toolPath := filepath.Join(dir, "expiringtool")
|
|
toolPath = writeProbeTool(t, toolPath, "version one")
|
|
|
|
results := RunProbesWithOverrides(context.Background(), []string{"expiringtool --version"}, map[string]string{"expiringtool": toolPath})
|
|
if got := results[0].Output; got != "version one" {
|
|
t.Fatalf("first Output = %q, want version one", got)
|
|
}
|
|
toolPath = writeProbeTool(t, toolPath, "version two")
|
|
setProbeNowForTest(now.Add(probeCacheTTL + time.Second))
|
|
|
|
results = RunProbesWithOverrides(context.Background(), []string{"expiringtool --version"}, map[string]string{"expiringtool": toolPath})
|
|
if got := results[0].Output; got != "version two" {
|
|
t.Fatalf("expired Output = %q, want version two", got)
|
|
}
|
|
}
|
|
|
|
func TestRunProbesCacheSeparatesOverrides(t *testing.T) {
|
|
resetProbeCacheForTest(t, time.Unix(300, 0))
|
|
dir := t.TempDir()
|
|
toolOne := filepath.Join(dir, "override-one")
|
|
toolTwo := filepath.Join(dir, "override-two")
|
|
toolOne = writeProbeTool(t, toolOne, "version one")
|
|
toolTwo = writeProbeTool(t, toolTwo, "version two")
|
|
|
|
results := RunProbesWithOverrides(context.Background(), []string{"overridetool --version"}, map[string]string{"overridetool": toolOne})
|
|
if got := results[0].Output; got != "version one" {
|
|
t.Fatalf("first override Output = %q, want version one", got)
|
|
}
|
|
results = RunProbesWithOverrides(context.Background(), []string{"overridetool --version"}, map[string]string{"overridetool": toolTwo})
|
|
if got := results[0].Output; got != "version two" {
|
|
t.Fatalf("second override Output = %q, want version two", got)
|
|
}
|
|
}
|
|
|
|
func TestFormatSectionLimitsToolOutput(t *testing.T) {
|
|
overrides := map[string]string{}
|
|
var results []ProbeResult
|
|
for i := 0; i < maxRenderedTools+2; i++ {
|
|
name := fmt.Sprintf("tool%02d", i)
|
|
overrides[name] = "/bin/" + name
|
|
results = append(results, ProbeResult{Binary: name, Found: true, Output: "ok"})
|
|
results = append(results, ProbeResult{Binary: "missing" + name, Error: "not found"})
|
|
}
|
|
|
|
section := FormatSection(results, "test/os", "", overrides)
|
|
for _, want := range []string{
|
|
"- ... 2 more configured tools omitted",
|
|
"- ... 2 more detected tools omitted",
|
|
"- ... 2 more unavailable tools omitted",
|
|
} {
|
|
if !strings.Contains(section, want) {
|
|
t.Fatalf("section missing %q:\n%s", want, section)
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeProbeTool(t *testing.T, path, output string) string {
|
|
t.Helper()
|
|
setProbeTimeoutForTest(t, 10*time.Second)
|
|
body := "#!/bin/sh\nprintf '%s\\n'\n"
|
|
body = fmt.Sprintf(body, strings.ReplaceAll(output, "'", "'\\''"))
|
|
if runtime.GOOS == "windows" {
|
|
if !strings.HasSuffix(path, ".bat") {
|
|
path += ".bat"
|
|
}
|
|
body = "@echo " + strings.ReplaceAll(output, "\n", "\r\n@echo ") + "\r\n"
|
|
}
|
|
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
|
t.Fatalf("write tool: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func writeEnvProbeTool(t *testing.T, path string) string {
|
|
t.Helper()
|
|
setProbeTimeoutForTest(t, 10*time.Second)
|
|
body := "#!/bin/sh\nprintf '%s\\n' \"$REASONIX_PROBE_ENV\"\n"
|
|
if runtime.GOOS == "windows" {
|
|
if !strings.HasSuffix(path, ".bat") {
|
|
path += ".bat"
|
|
}
|
|
body = "@echo %REASONIX_PROBE_ENV%\r\n"
|
|
}
|
|
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
|
t.Fatalf("write env tool: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func writeStderrProbeTool(t *testing.T, path, output string) string {
|
|
t.Helper()
|
|
setProbeTimeoutForTest(t, 10*time.Second)
|
|
body := "#!/bin/sh\nprintf '%s\\n' >&2\n"
|
|
body = fmt.Sprintf(body, strings.ReplaceAll(output, "'", "'\\''"))
|
|
if runtime.GOOS == "windows" {
|
|
if !strings.HasSuffix(path, ".bat") {
|
|
path += ".bat"
|
|
}
|
|
body = "@echo " + strings.ReplaceAll(output, "\n", "\r\n@echo ") + " 1>&2\r\n"
|
|
}
|
|
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
|
t.Fatalf("write stderr tool: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func resetProbeCacheForTest(t *testing.T, now time.Time) {
|
|
t.Helper()
|
|
setProbeNowForTest(now)
|
|
probeCacheMu.Lock()
|
|
probeCache = map[string]probeCacheEntry{}
|
|
probeInflightCalls = map[string]*probeInflight{}
|
|
probeCacheMu.Unlock()
|
|
t.Cleanup(func() {
|
|
probeCacheMu.Lock()
|
|
probeCache = map[string]probeCacheEntry{}
|
|
probeInflightCalls = map[string]*probeInflight{}
|
|
probeNow = time.Now
|
|
probeTimeout = ProbeTimeout
|
|
probeCacheMu.Unlock()
|
|
})
|
|
}
|
|
|
|
func setProbeNowForTest(now time.Time) {
|
|
probeCacheMu.Lock()
|
|
probeNow = func() time.Time { return now }
|
|
probeCacheMu.Unlock()
|
|
}
|
|
|
|
func setProbeTimeoutForTest(t *testing.T, timeout time.Duration) {
|
|
t.Helper()
|
|
probeCacheMu.Lock()
|
|
probeTimeout = timeout
|
|
probeCacheMu.Unlock()
|
|
t.Cleanup(func() {
|
|
probeCacheMu.Lock()
|
|
probeTimeout = ProbeTimeout
|
|
probeCacheMu.Unlock()
|
|
})
|
|
}
|
|
|
|
func TestRunProbesFilterSubprocessEnv(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("POSIX shell probe tool")
|
|
}
|
|
resetProbeCacheForTest(t, time.Unix(90, 0))
|
|
setProbeTimeoutForTest(t, 10*time.Second)
|
|
dir := t.TempDir()
|
|
toolPath := filepath.Join(dir, "envtool")
|
|
body := "#!/bin/sh\nprintf 'tok=%s' \"${REASONIX_TEST_SECRET_TOKEN:-none}\"\n"
|
|
if err := os.WriteFile(toolPath, []byte(body), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("REASONIX_TEST_SECRET_TOKEN", "ghp_abcdefghijklmnopqrstuvwxyz")
|
|
secrets.SetFilterSubprocessEnv(true)
|
|
t.Cleanup(func() { secrets.SetFilterSubprocessEnv(false) })
|
|
|
|
results := RunProbesWithOverrides(context.Background(), []string{"envtool --version"}, map[string]string{"envtool": toolPath})
|
|
if len(results) != 1 || !results[0].Found {
|
|
t.Fatalf("probe result = %+v", results)
|
|
}
|
|
// Probes declaring no extra env of their own must still get the filtered
|
|
// environment, not inherit the full one.
|
|
if results[0].Output != "tok=none" {
|
|
t.Fatalf("probe leaked filtered env: output = %q", results[0].Output)
|
|
}
|
|
}
|