From 0116797ff6d5a2ef62be603822eaa76916ed7e5d Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Fri, 2 Oct 2026 19:56:43 +0200 Subject: [PATCH] test(tui): isolate user home, config and cache dirs for the TUI tests newModel reads /zero/commands/*.md, and the package has 500+ newModel calls with no isolation, so a developer's own slash commands and state leaked into the TUI tests (and tests could write to real user directories), against the AGENTS.md "Hermetic tests" rule. Add testutil.IsolateUserDirs, which points HOME, USERPROFILE, APPDATA, LOCALAPPDATA and the XDG_* roots at a temp dir (covering Linux, macOS and Windows), and call it from a TestMain in internal/tui. Per-test t.Setenv overrides still win. Refs #1103 Co-Authored-By: Claude Sonnet 5.5 --- internal/testutil/userdirs.go | 45 ++++++++++++++++++++ internal/testutil/userdirs_test.go | 48 +++++++++++++++++++++ internal/tui/hermetic_test.go | 68 ++++++++++++++++++++++++++++++ internal/tui/main_test.go | 29 +++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 internal/testutil/userdirs.go create mode 100644 internal/testutil/userdirs_test.go create mode 100644 internal/tui/hermetic_test.go create mode 100644 internal/tui/main_test.go diff --git a/internal/testutil/userdirs.go b/internal/testutil/userdirs.go new file mode 100644 index 000000000..861d1099c --- /dev/null +++ b/internal/testutil/userdirs.go @@ -0,0 +1,45 @@ +package testutil + +import "os" + +// userDirEnv lists every variable that os.UserHomeDir, os.UserConfigDir and +// os.UserCacheDir (and Zero's own config.UserConfigDir) read, across Linux, +// macOS and Windows. Setting only the XDG ones still leaves macOS resolving +// from HOME and Windows from USERPROFILE, APPDATA and LOCALAPPDATA. +var userDirEnv = []string{ + "HOME", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", +} + +// IsolateUserDirs points every per-user directory root at root so code under +// test cannot read the developer's real config, cache or home directory, or +// write to them. It is meant for a package's TestMain, which has no *testing.T +// (tests that need their own layout still use t.Setenv, which wins while it +// is active). The returned function restores the previous environment. +func IsolateUserDirs(root string) (restore func()) { + type saved struct { + value string + set bool + } + previous := make(map[string]saved, len(userDirEnv)) + for _, name := range userDirEnv { + value, set := os.LookupEnv(name) + previous[name] = saved{value: value, set: set} + _ = os.Setenv(name, root) + } + return func() { + for name, prior := range previous { + if prior.set { + _ = os.Setenv(name, prior.value) + } else { + _ = os.Unsetenv(name) + } + } + } +} diff --git a/internal/testutil/userdirs_test.go b/internal/testutil/userdirs_test.go new file mode 100644 index 000000000..335667160 --- /dev/null +++ b/internal/testutil/userdirs_test.go @@ -0,0 +1,48 @@ +package testutil + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestIsolateUserDirsRedirectsAndRestores(t *testing.T) { + // Seed distinctive values so restoring is observable, then make sure the + // helper hands them back (set ones restored, unset ones unset again). + t.Setenv("HOME", "before-home") + t.Setenv("XDG_STATE_HOME", "before-state") + os.Unsetenv("XDG_DATA_HOME") + + root := t.TempDir() + restore := IsolateUserDirs(root) + + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + config, err := os.UserConfigDir() + if err != nil { + t.Fatal(err) + } + cache, err := os.UserCacheDir() + if err != nil { + t.Fatal(err) + } + for name, dir := range map[string]string{"home": home, "config": config, "cache": cache} { + if rel, err := filepath.Rel(root, dir); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + t.Errorf("%s dir %q is outside the isolated root %q", name, dir, root) + } + } + + restore() + if got := os.Getenv("HOME"); got != "before-home" { + t.Errorf("HOME after restore = %q, want before-home", got) + } + if got := os.Getenv("XDG_STATE_HOME"); got != "before-state" { + t.Errorf("XDG_STATE_HOME after restore = %q, want before-state", got) + } + if _, set := os.LookupEnv("XDG_DATA_HOME"); set { + t.Error("XDG_DATA_HOME was unset before and must be unset again after restore") + } +} diff --git a/internal/tui/hermetic_test.go b/internal/tui/hermetic_test.go new file mode 100644 index 000000000..eddd92ca4 --- /dev/null +++ b/internal/tui/hermetic_test.go @@ -0,0 +1,68 @@ +package tui + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/config" +) + +func underRoot(root, dir string) bool { + rel, err := filepath.Rel(root, dir) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// Guards TestMain: the per-user directories every newModel reads (and the ones +// tests could write to) must resolve inside the isolated root on every OS. +func TestUserDirsAreIsolatedFromTheDeveloperHome(t *testing.T) { + if isolatedUserDirsRoot == "" { + t.Fatal("TestMain did not isolate the user directories") + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + userConfig, err := config.UserConfigDir() + if err != nil { + t.Fatal(err) + } + cache, err := os.UserCacheDir() + if err != nil { + t.Fatal(err) + } + for name, dir := range map[string]string{"home": home, "config": userConfig, "cache": cache} { + if !underRoot(isolatedUserDirsRoot, dir) { + t.Errorf("%s dir %q is outside the isolated root %q", name, dir, isolatedUserDirsRoot) + } + } +} + +// newModel loads slash commands from /zero/commands. A fresh model +// must see none, and must see exactly what is planted in the isolated dir. +func TestNewModelLoadsUserCommandsOnlyFromIsolatedConfig(t *testing.T) { + if got := len(newModel(context.Background(), Options{Cwd: t.TempDir()}).userCommands); got != 0 { + t.Fatalf("fresh model loaded %d user command(s) from outside the test sandbox", got) + } + + userConfig, err := config.UserConfigDir() + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(userConfig, "zero", "commands") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "hermetic-probe.md") + if err := os.WriteFile(path, []byte("probe"), 0o644); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(path) }) + + commands := newModel(context.Background(), Options{Cwd: t.TempDir()}).userCommands + if len(commands) != 1 || commands[0].Name != "hermetic-probe" { + t.Fatalf("user commands = %#v, want only the planted hermetic-probe", commands) + } +} diff --git a/internal/tui/main_test.go b/internal/tui/main_test.go new file mode 100644 index 000000000..81a3f0ce0 --- /dev/null +++ b/internal/tui/main_test.go @@ -0,0 +1,29 @@ +package tui + +import ( + "fmt" + "os" + "testing" + + "github.com/Gitlawb/zero/internal/testutil" +) + +// isolatedUserDirsRoot is where TestMain points every per-user directory. +var isolatedUserDirsRoot string + +// TestMain keeps the whole package off the developer's real home, config and +// cache directories. newModel loads the user's slash commands from the config +// dir, so without this every test sees (and could write to) real user state. +func TestMain(m *testing.M) { + root, err := os.MkdirTemp("", "zero-tui-test-home-") + if err != nil { + fmt.Fprintln(os.Stderr, "tui tests: create isolated user dirs:", err) + os.Exit(1) + } + isolatedUserDirsRoot = root + restore := testutil.IsolateUserDirs(root) + code := m.Run() + restore() + _ = os.RemoveAll(root) + os.Exit(code) +}