| index b8711d2d9..c37400943 100644 |
| --- a/main_test.go |
| +++ b/main_test.go |
| @@ -1,16 +1,290 @@ |
| package main |
| |
| import ( |
| + "errors" |
| + "fmt" |
| "os" |
| + "path/filepath" |
| + "runtime" |
| + "strings" |
| "testing" |
| ) |
| |
| -// TestMain keeps an exported deploy token out of the root package's tests. |
| -// resolveMode reads CARLOS_SERVICE_TOKEN, and a CI shell running `go test` |
| -// may well export one. In-process tests that call run() or a verb directly |
| -// would then inherit it. Tests that want a token set it with t.Setenv, which |
| -// still works after this Unsetenv. |
| +// TestMain keeps the operator's own state out of the root package's tests. |
| +// |
| +// Three exported variables are dropped before any test runs. No shared |
| +// helper pins all three, and each steers a test that calls run() or a verb |
| +// directly, and every child that inherits os.Environ(). A test that wants |
| +// one sets it with t.Setenv, which still works after an Unsetenv. |
| +// |
| +// - CARLOS_SERVICE_TOKEN: resolveMode reads it, and a CI shell running |
| +// `go test` may well export one. |
| +// - CARLOS_CONFIG: it names the one config file read and switches |
| +// discovery off, so with it exported every test that relies on its |
| +// scratch directory holding no config reads the developer's file |
| +// instead, and ships to the console it names. |
| +// - CARLOS_DESTINATION: it selects a block in whatever config a test |
| +// wrote, and a config that does not define it is an error. |
| +// |
| +// CARLOS_CONSOLE and CARLOS_CREDENTIALS stay: the shared helpers pin both. |
| +// HOME stays too, because the tests' own `go build` needs the real one. |
| +// |
| +// The operator's .carlos/config: see isolateTempRoot. The binary refuses to |
| +// run rather than work under one. |
| func TestMain(m *testing.M) { |
| - os.Unsetenv("CARLOS_SERVICE_TOKEN") |
| - os.Exit(m.Run()) |
| + for _, name := range []string{"CARLOS_SERVICE_TOKEN", "CARLOS_CONFIG", "CARLOS_DESTINATION"} { |
| + os.Unsetenv(name) |
| + } |
| + // /var/tmp alone. /tmp is not offered: the tests build binaries into |
| + // the temp root, and on a machine where /tmp is shared memory the move |
| + // would put them there without anyone having asked. |
| + moved, cleanup, err := isolateTempRoot([]string{"/var/tmp"}) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| + if moved != "" { |
| + fmt.Fprintln(os.Stderr, moved) |
| + } |
| + code := m.Run() |
| + cleanup() |
| + os.Exit(code) |
| +} |
| + |
| +// tempRootPrefix names the directory isolateTempRoot makes. Its cleanup runs |
| +// only when the tests return: a log.Fatal, a panic or a timeout leaves that |
| +// one directory behind, and the line TestMain prints says where it is. |
| +const tempRootPrefix = "carlos-test-" |
| + |
| +// isolateTempRoot makes sure no .carlos/config sits above the directories |
| +// the tests' scratch space comes from, before any test asks for one. |
| +// |
| +// The tests pin HOME, but the CLI's config discovery (config.go |
| +// walkConfigFiles) walks up from the working directory, and that directory |
| +// is a t.TempDir() for every test that uses useTempCredentials or runs the |
| +// binary from a scratch cmd.Dir. Where the temp root is inside a logged-in |
| +// operator's home (TMPDIR=~/.cache/tmp/..., the default in a sandboxed agent |
| +// session) the walk reached the real ~/.carlos/config: two dozen tests |
| +// failed with "not logged in" to the operator's console, and one offered a |
| +// re-login to it and waited ten minutes (#226, #258). Pinning CARLOS_CONFIG |
| +// in the shared helpers is not the fix, because it switches discovery off |
| +// for the tests that write a .carlos/config of their own. |
| +// |
| +// There are two roots. os.TempDir() is TMPDIR. t.TempDir() is GOTMPDIR |
| +// when that is set and os.TempDir() only when it is not (testing.go |
| +// makeTempDir: os.MkdirTemp(os.Getenv("GOTMPDIR"), pattern)), so a clear |
| +// TMPDIR says nothing about a GOTMPDIR under a home. |
| +// |
| +// Roots with nothing above them are left alone, which is every CI run. |
| +// Otherwise the tests get one fresh directory under the first of bases that |
| +// is clear, and each variable whose root was not clear is pointed at it, so |
| +// child processes follow. An explicit GOTMPDIR is pointed there rather than |
| +// cleared: cleared, t.TempDir() would fall back to TMPDIR, which may be the |
| +// very place the developer set GOTMPDIR to keep the tests' binaries off. |
| +// moved says where, and the returned cleanup removes it. When no directory |
| +// can be made the variables are put back, the error names the ones to set, |
| +// and TestMain stops. |
| +func isolateTempRoot(bases []string) (moved string, cleanup func(), err error) { |
| + var why, names []string |
| + if p := configAbove(os.TempDir()); p != "" { |
| + why = append(why, fmt.Sprintf("%s is above the temp directory %s", p, os.TempDir())) |
| + names = append(names, "TMPDIR") |
| + } |
| + if root := os.Getenv("GOTMPDIR"); root != "" { |
| + if p := configAbove(root); p != "" { |
| + why = append(why, fmt.Sprintf("%s is above GOTMPDIR %s", p, root)) |
| + names = append(names, "GOTMPDIR") |
| + } |
| + } |
| + if len(names) == 0 { |
| + return "", func() {}, nil |
| + } |
| + type was struct { |
| + value string |
| + set bool |
| + } |
| + before := map[string]was{} |
| + for _, name := range names { |
| + v, ok := os.LookupEnv(name) |
| + before[name] = was{v, ok} |
| + } |
| + for _, base := range bases { |
| + dir, err := os.MkdirTemp(base, tempRootPrefix) |
| + if err != nil { |
| + continue |
| + } |
| + for _, name := range names { |
| + os.Setenv(name, dir) |
| + } |
| + // Asked of both roots again rather than assumed: the base may sit |
| + // under a config too, and not every platform reads TMPDIR. |
| + if configAbove(os.TempDir()) == "" && configAbove(os.Getenv("GOTMPDIR")) == "" { |
| + moved = fmt.Sprintf("carlos tests: %s, so the tests work in %s instead (an aborted run leaves it behind)", strings.Join(why, " and "), dir) |
| + return moved, func() { os.RemoveAll(dir) }, nil |
| + } |
| + os.Remove(dir) |
| + for name, w := range before { |
| + if w.set { |
| + os.Setenv(name, w.value) |
| + } else { |
| + os.Unsetenv(name) |
| + } |
| + } |
| + } |
| + return "", nil, fmt.Errorf("carlos tests: %s, and the CLI's config walk would read it. No directory could be made under %s to work in instead: set %s to a directory with no .carlos/config above it", |
| + strings.Join(why, " and "), strings.Join(bases, " or "), strings.Join(names, " and ")) |
| +} |
| + |
| +// configAbove is the first .carlos/config the CLI's discovery would read |
| +// walking up from dir, "" when there is none or dir is "". The walk is over |
| +// the path as written, which is what discovery follows after a t.Chdir, and |
| +// over the path with its symlinks resolved, which is what it follows when |
| +// PWD is unset. Anything but "does not exist" counts as there: discovery |
| +// fails on a config it cannot read. |
| +func configAbove(dir string) string { |
| + if dir == "" { |
| + return "" |
| + } |
| + if abs, err := filepath.Abs(dir); err == nil { |
| + dir = abs |
| + } |
| + starts := []string{dir} |
| + if resolved, err := filepath.EvalSymlinks(dir); err == nil && resolved != dir { |
| + starts = append(starts, resolved) |
| + } |
| + for _, d := range starts { |
| + for { |
| + path := filepath.Join(d, ".carlos", "config") |
| + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { |
| + return path |
| + } |
| + parent := filepath.Dir(d) |
| + if parent == d { |
| + break |
| + } |
| + d = parent |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// TestMain's own promises, read back from inside a test: neither root has a |
| +// config above it, and none of the variables it drops came through. |
| +func TestMainLeavesNoOperatorState(t *testing.T) { |
| + for _, root := range []string{os.TempDir(), os.Getenv("GOTMPDIR"), t.TempDir()} { |
| + if p := configAbove(root); p != "" { |
| + t.Errorf("TestMain let the tests run with %s above %s", p, root) |
| + } |
| + } |
| + for _, name := range []string{"CARLOS_SERVICE_TOKEN", "CARLOS_CONFIG", "CARLOS_DESTINATION"} { |
| + if v, ok := os.LookupEnv(name); ok { |
| + t.Errorf("%s=%q reached the tests", name, v) |
| + } |
| + } |
| +} |
| + |
| +// The guard, against a tree of its own. A root under a config moves to the |
| +// first base that is clear: TMPDIR, GOTMPDIR or both, whichever was under |
| +// it, and only those. The cleanup removes the directory and nothing else. |
| +// With no clear base the binary is told to stop and what to set. |
| +func TestIsolateTempRoot(t *testing.T) { |
| + if runtime.GOOS == "windows" { |
| + t.Skip("os.TempDir does not read TMPDIR on windows, so there is no moving it") |
| + } |
| + home := t.TempDir() |
| + planted := writeConfig(t, home, `{"console": "https://operator.example"}`) |
| + under := filepath.Join(home, "cache", "tmp") |
| + alsoUnder := filepath.Join(home, "var") |
| + clear, otherClear := t.TempDir(), t.TempDir() |
| + for _, d := range []string{under, alsoUnder} { |
| + if err := os.MkdirAll(d, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if got := configAbove(under); got != planted { |
| + t.Fatalf("configAbove(%s) = %q, want %s", under, got, planted) |
| + } |
| + bases := []string{filepath.Join(home, "missing"), alsoUnder, clear} |
| + |
| + for _, c := range []struct { |
| + name string |
| + tmpdir, gotmp string // as the run starts; "" is unset |
| + moveTmp bool |
| + moveGo bool |
| + }{ |
| + {"TMPDIR under a config, no GOTMPDIR", under, "", true, false}, |
| + {"TMPDIR under a config, GOTMPDIR clear", under, otherClear, true, false}, |
| + {"GOTMPDIR under a config, TMPDIR clear", otherClear, under, false, true}, |
| + {"both under a config", under, alsoUnder, true, true}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + t.Setenv("TMPDIR", c.tmpdir) |
| + t.Setenv("GOTMPDIR", c.gotmp) |
| + said, cleanup, err := isolateTempRoot(bases) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + made, _ := os.ReadDir(clear) |
| + if len(made) != 1 || !strings.HasPrefix(made[0].Name(), tempRootPrefix) { |
| + t.Fatalf("the clear base holds %v, want one %s directory", made, tempRootPrefix) |
| + } |
| + moved := filepath.Join(clear, made[0].Name()) |
| + wantTmp, wantGo := c.tmpdir, c.gotmp |
| + if c.moveTmp { |
| + wantTmp = moved |
| + } |
| + if c.moveGo { |
| + wantGo = moved |
| + } |
| + if os.TempDir() != wantTmp || os.Getenv("GOTMPDIR") != wantGo { |
| + t.Fatalf("TMPDIR=%s GOTMPDIR=%s, want %s and %s", os.TempDir(), os.Getenv("GOTMPDIR"), wantTmp, wantGo) |
| + } |
| + if !strings.Contains(said, planted) || !strings.Contains(said, moved) || |
| + strings.Contains(said, "above GOTMPDIR") != c.moveGo || strings.Contains(said, "above the temp directory") != c.moveTmp { |
| + t.Fatalf("said %q, want %s, %s and the roots that moved", said, planted, moved) |
| + } |
| + // What the guard is for: a test's own temp directory is clear now. |
| + t.Run("a test's temp directory", func(t *testing.T) { |
| + if p := configAbove(t.TempDir()); p != "" { |
| + t.Fatalf("t.TempDir() is still under %s", p) |
| + } |
| + }) |
| + if left, _ := os.ReadDir(alsoUnder); len(left) != 0 { |
| + t.Fatalf("the refused base kept %v", left) |
| + } |
| + cleanup() |
| + if _, err := os.Stat(moved); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the cleanup left %s (%v)", moved, err) |
| + } |
| + if _, err := os.Stat(clear); err != nil { |
| + t.Fatalf("the cleanup took the base with it: %v", err) |
| + } |
| + }) |
| + } |
| + |
| + // No clear base: an error naming the config and each variable to set, |
| + // with both variables as they were. |
| + t.Setenv("TMPDIR", otherClear) |
| + t.Setenv("GOTMPDIR", under) |
| + _, _, err := isolateTempRoot([]string{alsoUnder}) |
| + if err == nil || !strings.Contains(err.Error(), planted) || !strings.Contains(err.Error(), "set GOTMPDIR to") { |
| + t.Fatalf("no clear base = %v, want an error naming %s and GOTMPDIR", err, planted) |
| + } |
| + if os.TempDir() != otherClear || os.Getenv("GOTMPDIR") != under { |
| + t.Fatalf("a refusal left TMPDIR=%s GOTMPDIR=%s", os.TempDir(), os.Getenv("GOTMPDIR")) |
| + } |
| + t.Setenv("TMPDIR", under) |
| + if _, _, err := isolateTempRoot([]string{alsoUnder}); err == nil || !strings.Contains(err.Error(), "set TMPDIR and GOTMPDIR to") { |
| + t.Fatalf("no clear base, both roots = %v, want both variables named", err) |
| + } |
| + if left, _ := os.ReadDir(alsoUnder); len(left) != 0 { |
| + t.Fatalf("the refused base kept %v", left) |
| + } |
| + |
| + // Clear roots are not moved at all. |
| + t.Setenv("TMPDIR", clear) |
| + t.Setenv("GOTMPDIR", otherClear) |
| + if said, _, err := isolateTempRoot([]string{filepath.Join(home, "missing")}); err != nil || said != "" || os.TempDir() != clear || os.Getenv("GOTMPDIR") != otherClear { |
| + t.Fatalf("clear roots = %s, %s, %q, %v; want them left alone", os.TempDir(), os.Getenv("GOTMPDIR"), said, err) |
| + } |
| } |