Skip to content

Commit 0116797

Browse files
PierrunoYTclaude
andcommitted
test(tui): isolate user home, config and cache dirs for the TUI tests
newModel reads <user config>/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 <noreply@anthropic.com>
1 parent 99721c7 commit 0116797

4 files changed

Lines changed: 190 additions & 0 deletions

File tree

‎internal/testutil/userdirs.go‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package testutil
2+
3+
import "os"
4+
5+
// userDirEnv lists every variable that os.UserHomeDir, os.UserConfigDir and
6+
// os.UserCacheDir (and Zero's own config.UserConfigDir) read, across Linux,
7+
// macOS and Windows. Setting only the XDG ones still leaves macOS resolving
8+
// from HOME and Windows from USERPROFILE, APPDATA and LOCALAPPDATA.
9+
var userDirEnv = []string{
10+
"HOME",
11+
"USERPROFILE",
12+
"APPDATA",
13+
"LOCALAPPDATA",
14+
"XDG_CONFIG_HOME",
15+
"XDG_CACHE_HOME",
16+
"XDG_DATA_HOME",
17+
"XDG_STATE_HOME",
18+
}
19+
20+
// IsolateUserDirs points every per-user directory root at root so code under
21+
// test cannot read the developer's real config, cache or home directory, or
22+
// write to them. It is meant for a package's TestMain, which has no *testing.T
23+
// (tests that need their own layout still use t.Setenv, which wins while it
24+
// is active). The returned function restores the previous environment.
25+
func IsolateUserDirs(root string) (restore func()) {
26+
type saved struct {
27+
value string
28+
set bool
29+
}
30+
previous := make(map[string]saved, len(userDirEnv))
31+
for _, name := range userDirEnv {
32+
value, set := os.LookupEnv(name)
33+
previous[name] = saved{value: value, set: set}
34+
_ = os.Setenv(name, root)
35+
}
36+
return func() {
37+
for name, prior := range previous {
38+
if prior.set {
39+
_ = os.Setenv(name, prior.value)
40+
} else {
41+
_ = os.Unsetenv(name)
42+
}
43+
}
44+
}
45+
}

‎internal/testutil/userdirs_test.go‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package testutil
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestIsolateUserDirsRedirectsAndRestores(t *testing.T) {
11+
// Seed distinctive values so restoring is observable, then make sure the
12+
// helper hands them back (set ones restored, unset ones unset again).
13+
t.Setenv("HOME", "before-home")
14+
t.Setenv("XDG_STATE_HOME", "before-state")
15+
os.Unsetenv("XDG_DATA_HOME")
16+
17+
root := t.TempDir()
18+
restore := IsolateUserDirs(root)
19+
20+
home, err := os.UserHomeDir()
21+
if err != nil {
22+
t.Fatal(err)
23+
}
24+
config, err := os.UserConfigDir()
25+
if err != nil {
26+
t.Fatal(err)
27+
}
28+
cache, err := os.UserCacheDir()
29+
if err != nil {
30+
t.Fatal(err)
31+
}
32+
for name, dir := range map[string]string{"home": home, "config": config, "cache": cache} {
33+
if rel, err := filepath.Rel(root, dir); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
34+
t.Errorf("%s dir %q is outside the isolated root %q", name, dir, root)
35+
}
36+
}
37+
38+
restore()
39+
if got := os.Getenv("HOME"); got != "before-home" {
40+
t.Errorf("HOME after restore = %q, want before-home", got)
41+
}
42+
if got := os.Getenv("XDG_STATE_HOME"); got != "before-state" {
43+
t.Errorf("XDG_STATE_HOME after restore = %q, want before-state", got)
44+
}
45+
if _, set := os.LookupEnv("XDG_DATA_HOME"); set {
46+
t.Error("XDG_DATA_HOME was unset before and must be unset again after restore")
47+
}
48+
}

‎internal/tui/hermetic_test.go‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package tui
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/Gitlawb/zero/internal/config"
11+
)
12+
13+
func underRoot(root, dir string) bool {
14+
rel, err := filepath.Rel(root, dir)
15+
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
16+
}
17+
18+
// Guards TestMain: the per-user directories every newModel reads (and the ones
19+
// tests could write to) must resolve inside the isolated root on every OS.
20+
func TestUserDirsAreIsolatedFromTheDeveloperHome(t *testing.T) {
21+
if isolatedUserDirsRoot == "" {
22+
t.Fatal("TestMain did not isolate the user directories")
23+
}
24+
home, err := os.UserHomeDir()
25+
if err != nil {
26+
t.Fatal(err)
27+
}
28+
userConfig, err := config.UserConfigDir()
29+
if err != nil {
30+
t.Fatal(err)
31+
}
32+
cache, err := os.UserCacheDir()
33+
if err != nil {
34+
t.Fatal(err)
35+
}
36+
for name, dir := range map[string]string{"home": home, "config": userConfig, "cache": cache} {
37+
if !underRoot(isolatedUserDirsRoot, dir) {
38+
t.Errorf("%s dir %q is outside the isolated root %q", name, dir, isolatedUserDirsRoot)
39+
}
40+
}
41+
}
42+
43+
// newModel loads slash commands from <user config>/zero/commands. A fresh model
44+
// must see none, and must see exactly what is planted in the isolated dir.
45+
func TestNewModelLoadsUserCommandsOnlyFromIsolatedConfig(t *testing.T) {
46+
if got := len(newModel(context.Background(), Options{Cwd: t.TempDir()}).userCommands); got != 0 {
47+
t.Fatalf("fresh model loaded %d user command(s) from outside the test sandbox", got)
48+
}
49+
50+
userConfig, err := config.UserConfigDir()
51+
if err != nil {
52+
t.Fatal(err)
53+
}
54+
dir := filepath.Join(userConfig, "zero", "commands")
55+
if err := os.MkdirAll(dir, 0o755); err != nil {
56+
t.Fatal(err)
57+
}
58+
path := filepath.Join(dir, "hermetic-probe.md")
59+
if err := os.WriteFile(path, []byte("probe"), 0o644); err != nil {
60+
t.Fatal(err)
61+
}
62+
t.Cleanup(func() { _ = os.Remove(path) })
63+
64+
commands := newModel(context.Background(), Options{Cwd: t.TempDir()}).userCommands
65+
if len(commands) != 1 || commands[0].Name != "hermetic-probe" {
66+
t.Fatalf("user commands = %#v, want only the planted hermetic-probe", commands)
67+
}
68+
}

‎internal/tui/main_test.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package tui
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"testing"
7+
8+
"github.com/Gitlawb/zero/internal/testutil"
9+
)
10+
11+
// isolatedUserDirsRoot is where TestMain points every per-user directory.
12+
var isolatedUserDirsRoot string
13+
14+
// TestMain keeps the whole package off the developer's real home, config and
15+
// cache directories. newModel loads the user's slash commands from the config
16+
// dir, so without this every test sees (and could write to) real user state.
17+
func TestMain(m *testing.M) {
18+
root, err := os.MkdirTemp("", "zero-tui-test-home-")
19+
if err != nil {
20+
fmt.Fprintln(os.Stderr, "tui tests: create isolated user dirs:", err)
21+
os.Exit(1)
22+
}
23+
isolatedUserDirsRoot = root
24+
restore := testutil.IsolateUserDirs(root)
25+
code := m.Run()
26+
restore()
27+
_ = os.RemoveAll(root)
28+
os.Exit(code)
29+
}

0 commit comments

Comments
 (0)