Merge branch 'test-config-isolation' into main
5 files changed,
+86
−8
AGENTS.md+7 −0accounts_list_test.go+2 −2apimode_test.go+3 −3config.go+31 −0config_test.go+43 −3
CI passed — run details
diff --git a/AGENTS.md b/AGENTS.md| index 3447c3be1..20a55f2b3 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -229,6 +229,13 @@ anything. |
| `.carlos/config`. The fallback is the operator's real login and default |
| console; a logged-in laptop running `go test ./...` minted 40+ real accounts |
| on the production console before this was isolated. |
| +- **A known-failing test is a defect in its own right.** `log.Fatal` inside |
| + an in-process test aborts the whole binary rather than failing the one |
| + test, so everything after it silently never runs — and what gets read |
| + afterward is the failure COUNT, not the names. A red root package sat |
| + "known-failing" for a week while `TestStoreVerbDispatchAndRefusals` hid a |
| + stale usage assertion behind it, and CI stayed red on every push with |
| + nobody the wiser (discussion #17). |
| - **Absence of `apps/<acct>/<app>/pipeline.json` = the legacy ladder**, |
| byte-for-byte; new apps get a one-channel `edge` pipeline at create; |
| per-channel rules are console-enforced, bake windows box-enforced through |
diff --git a/accounts_list_test.go b/accounts_list_test.go| index 3bb1a8610..de32f4bc0 100644 |
| --- a/accounts_list_test.go |
| +++ b/accounts_list_test.go |
| @@ -31,7 +31,7 @@ func signedInConsole(t *testing.T, handler http.HandlerFunc) string { |
| // accounts list, signed-in: no registry on this machine, a credential and a |
| // console that answers — the no-infra path #157 was filed for. |
| func TestAccountsListSignedIn(t *testing.T) { |
| - useTempCredentials(t) |
| + useIsolatedConfig(t) |
| signedInConsole(t, func(w http.ResponseWriter, r *http.Request) { |
| if r.URL.Path != "/api/cli/apps" || r.Header.Get("Authorization") != "Bearer tok" { |
| http.NotFound(w, r) |
| @@ -58,7 +58,7 @@ func TestAccountsListSignedIn(t *testing.T) { |
| // A console from before the listing endpoint gets a plain named refusal, |
| // not the membership-404 riddle. |
| func TestAccountsListOldConsole(t *testing.T) { |
| - useTempCredentials(t) |
| + useIsolatedConfig(t) |
| url := signedInConsole(t, func(w http.ResponseWriter, r *http.Request) { |
| http.NotFound(w, r) |
| }) |
diff --git a/apimode_test.go b/apimode_test.go| index c459e9004..681094f26 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -85,7 +85,7 @@ func TestResolveModeDirWins(t *testing.T) { |
| } |
| func TestResolveModeAPI(t *testing.T) { |
| - useTempCredentials(t) |
| + useIsolatedConfig(t) |
| clearDeploymentEnv(t) |
| f, _ := loadCredentials() |
| @@ -113,7 +113,7 @@ func TestResolveModeAPI(t *testing.T) { |
| } |
| func TestResolveModeNothing(t *testing.T) { |
| - useTempCredentials(t) |
| + useIsolatedConfig(t) |
| clearDeploymentEnv(t) |
| forceTerminal(t, false) // pin: this asserts the NON-interactive refusal |
| @@ -137,7 +137,7 @@ func TestResolveModeNothing(t *testing.T) { |
| // all, and deserves a message that says so instead of offering to set up |
| // direct mode from scratch. |
| func TestResolveModeExpiredCredentialOnly(t *testing.T) { |
| - useTempCredentials(t) |
| + useIsolatedConfig(t) |
| clearDeploymentEnv(t) |
| forceTerminal(t, false) // pin: this asserts the NON-interactive refusal |
diff --git a/config.go b/config.go| index efa918da0..b63847ab5 100644 |
| --- a/config.go |
| +++ b/config.go |
| @@ -59,7 +59,23 @@ type cliConfig struct { |
| // that pick() accepts, returning it with its path. The zero config and "" |
| // mean "no file said anything". Every per-key default walk (account, app, |
| // update_check) is this walk with a different pick. |
| +// |
| +// CARLOS_CONFIG, when set, skips this discovery entirely: the named file is |
| +// the only one consulted, whether or not pick() accepts it. This is the |
| +// override configDefault documents in the same terms as CARLOS_CREDENTIALS |
| +// (auth.go credentialsPath) — for tests, and for anyone keeping their |
| +// config somewhere other than $HOME's discovery path. |
| func walkConfig(pick func(cliConfig) bool) (cliConfig, string, error) { |
| + if p := os.Getenv("CARLOS_CONFIG"); p != "" { |
| + cfg, err := loadConfig(p) |
| + if err != nil { |
| + return cliConfig{}, "", err |
| + } |
| + if !pick(cfg) { |
| + return cliConfig{}, "", nil |
| + } |
| + return cfg, p, nil |
| + } |
| if dir, werr := os.Getwd(); werr == nil { |
| for { |
| path := filepath.Join(dir, ".carlos", "config") |
| @@ -164,7 +180,22 @@ func loadConfigFile(path string) (string, error) { |
| // rule — the global default arriving one step early, same answer. The |
| // explicit global check after the walk is for working directories outside |
| // $HOME entirely. |
| +// |
| +// CARLOS_CONFIG, when set, names the one file consulted and skips discovery |
| +// entirely — see walkConfig's identical override, which every other |
| +// per-key default (account, app, update_check) already goes through. An |
| +// unset CARLOS_CONFIG leaves this walk exactly as it was. |
| func configDefault() (console, source string, err error) { |
| + if p := os.Getenv("CARLOS_CONFIG"); p != "" { |
| + c, err := loadConfigFile(p) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + if c == "" { |
| + return "", "", nil |
| + } |
| + return c, p, nil |
| + } |
| if dir, werr := os.Getwd(); werr == nil { |
| for { |
| path := filepath.Join(dir, ".carlos", "config") |
diff --git a/config_test.go b/config_test.go| index d727a1356..1089631c0 100644 |
| --- a/config_test.go |
| +++ b/config_test.go |
| @@ -24,6 +24,33 @@ func writeConfig(t *testing.T, dir, body string) string { |
| return path |
| } |
| +// useIsolatedConfig isolates an in-process test the way carlosTestEnv |
| +// isolates a subprocess (e2e_test.go): CARLOS_CONFIG is pointed at a path |
| +// inside a fresh t.TempDir() where no config exists, which makes walkConfig |
| +// and configDefault skip discovery entirely — no cwd walk, no global |
| +// fallback — so a developer's real ~/.carlos/config can never answer for a |
| +// test. HOME is also set to a separate scratch dir (some paths, like |
| +// credentialsPath's fallback, still derive from it), and CARLOS_CONSOLE and |
| +// CARLOS_CREDENTIALS are cleared so neither leaks in from the environment |
| +// running the test binary. |
| +// |
| +// This is deliberately an env override rather than a chdir: t.Chdir forbids |
| +// t.Parallel(), and CARLOS_CONFIG makes the cwd walk irrelevant for any test |
| +// that isn't exercising discovery itself. TestConfigDefaultWalk is the one |
| +// test that genuinely needs the walk, so it builds its own tree with |
| +// t.Chdir instead of calling this helper. |
| +// |
| +// Duplicated wherever package main_test needs the same isolation: this file |
| +// is package main, e2e_test.go is an external test package, and the two |
| +// cannot share an unexported helper across the package boundary. |
| +func useIsolatedConfig(t *testing.T) { |
| + t.Helper() |
| + t.Setenv("HOME", t.TempDir()) |
| + t.Setenv("CARLOS_CONFIG", filepath.Join(t.TempDir(), "config")) |
| + t.Setenv("CARLOS_CONSOLE", "") |
| + t.Setenv("CARLOS_CREDENTIALS", "") |
| +} |
| + |
| func TestLoadConfigFile(t *testing.T) { |
| useTempCredentials(t) |
| dir := t.TempDir() |
| @@ -60,9 +87,23 @@ func TestLoadConfigFile(t *testing.T) { |
| } |
| } |
| +// TestConfigDefaultWalk exercises discovery itself — the cwd walk AND the |
| +// global fallback — so it cannot use useIsolatedConfig's CARLOS_CONFIG |
| +// override, which disables discovery entirely. Instead it owns one temp |
| +// tree for the whole test: HOME lives at the tree's root, and the working |
| +// directory is a descendant of that same root, so both the cwd walk and |
| +// the global fallback resolve inside the tree this test controls rather |
| +// than a developer's real ~/.carlos/config. |
| func TestConfigDefaultWalk(t *testing.T) { |
| - useTempCredentials(t) |
| - home := os.Getenv("HOME") |
| + home := t.TempDir() |
| + t.Setenv("HOME", home) |
| + t.Setenv("CARLOS_CONSOLE", "") |
| + t.Setenv("CARLOS_CREDENTIALS", filepath.Join(home, "credentials")) |
| + root := filepath.Join(home, "project") |
| + if err := os.MkdirAll(root, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Chdir(root) |
| // Nothing anywhere: no default, no error. |
| if c, src, err := configDefault(); err != nil || c != "" || src != "" { |
| @@ -78,7 +119,6 @@ func TestConfigDefaultWalk(t *testing.T) { |
| // A project config anywhere up the cwd walk beats the global, and the |
| // NEAREST project config beats a farther one. |
| - root := t.TempDir() |
| writeConfig(t, root, `{"console": "https://outer.example"}`) |
| nested := filepath.Join(root, "cmd", "deep") |
| if err := os.MkdirAll(nested, 0o755); err != nil { |