package main import ( "bytes" "encoding/json" "os" "path/filepath" "strings" "testing" "time" ) // writeConfig plants a .carlos/config under dir, the way a checked-in // project file or `carlos auth default` would. func writeConfig(t *testing.T, dir, body string) string { t.Helper() path := filepath.Join(dir, ".carlos", "config") if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } 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", "") t.Setenv("CARLOS_DESTINATION", "") } func TestLoadConfigFile(t *testing.T) { useTempCredentials(t) dir := t.TempDir() // Missing file: no default at this level, not an error. if c, err := loadConfigFile(filepath.Join(dir, ".carlos", "config")); err != nil || c != "" { t.Fatalf("missing config = %q, %v; want empty, nil", c, err) } // A well-formed file: the console comes back normalized. p := writeConfig(t, dir, `{"console": "https://tito.example/"}`+"\n") if c, err := loadConfigFile(p); err != nil || c != "https://tito.example" { t.Fatalf("loadConfigFile = %q, %v; want the normalized console", c, err) } // Unknown fields are ignored (additive evolution), and a file WITHOUT // a console field is "no default at this level", not an error. os.WriteFile(p, []byte(`{"future": true}`), 0o644) if c, err := loadConfigFile(p); err != nil || c != "" { t.Fatalf("config without console = %q, %v; want empty, nil", c, err) } // Malformed JSON is an error naming the file — silently skipping a // typo'd config would ship to the wrong console. os.WriteFile(p, []byte(`{`), 0o644) if _, err := loadConfigFile(p); err == nil || !strings.Contains(err.Error(), p) { t.Fatalf("malformed config = %v, want an error naming %s", err, p) } // A console normalizeConsole rejects is an error too, same reasoning. os.WriteFile(p, []byte(`{"console": "ftp://nope"}`), 0o644) if _, err := loadConfigFile(p); err == nil || !strings.Contains(err.Error(), p) { t.Fatalf("bad-URL config = %v, want an error naming %s", err, p) } } // 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) { 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 != "" { t.Fatalf("bare configDefault = %q %q %v, want empty", c, src, err) } // Only the global: the global wins, and the source names the file. globalPath := writeConfig(t, home, `{"console": "https://global.example"}`) c, src, err := configDefault() if err != nil || c != "https://global.example" || src != globalPath { t.Fatalf("global-only = %q %q %v", c, src, err) } // A project config anywhere up the cwd walk beats the global, and the // NEAREST project config beats a farther one. writeConfig(t, root, `{"console": "https://outer.example"}`) nested := filepath.Join(root, "cmd", "deep") if err := os.MkdirAll(nested, 0o755); err != nil { t.Fatal(err) } t.Chdir(nested) if c, _, err = configDefault(); err != nil || c != "https://outer.example" { t.Fatalf("walk from nested = %q %v, want the ancestor's config", c, err) } nearest := writeConfig(t, nested, `{"console": "https://inner.example"}`) if c, src, err = configDefault(); err != nil || c != "https://inner.example" || src != nearest { t.Fatalf("nearest = %q %q %v", c, src, err) } // A level whose config has no console does not stop the walk. os.WriteFile(nearest, []byte(`{"future": true}`), 0o644) if c, _, err = configDefault(); err != nil || c != "https://outer.example" { t.Fatalf("empty-console level = %q %v, want the walk to continue", c, err) } // A malformed config mid-walk is an error, not a skip. os.WriteFile(nearest, []byte(`not json`), 0o644) if _, _, err = configDefault(); err == nil { t.Fatal("malformed project config must be an error, not a skip") } } func TestSaveConfigFile(t *testing.T) { useTempCredentials(t) dir := t.TempDir() path := filepath.Join(dir, ".carlos", "config") // The URL is validated and normalized on WRITE, so a bad default is // caught when set, not at first use. if err := saveConfigFile(path, "ftp://nope", 0o755); err == nil { t.Fatal("saving an invalid console must fail") } if err := saveConfigFile(path, "https://tito.example/", 0o755); err != nil { t.Fatal(err) } if c, err := loadConfigFile(path); err != nil || c != "https://tito.example" { t.Fatalf("round-trip = %q, %v", c, err) } // Not a secret: world-readable is fine and expected in a repo. fi, err := os.Stat(path) if err != nil { t.Fatal(err) } if fi.Mode().Perm() != 0o644 { t.Fatalf("config mode = %v, want 0644", fi.Mode().Perm()) } } func TestPickHonorsConfigDefault(t *testing.T) { useTempCredentials(t) home := os.Getenv("HOME") cwd, err := os.Getwd() if err != nil { t.Fatal(err) } now := time.Now().UTC() f, _ := loadCredentials() f.put(credential{Console: "https://flagship.example", Address: "p@keymail.dev", Token: "t-flag", ExpiresAt: now.Add(time.Hour)}) f.put(credential{Console: "https://tito.example", Address: "p@keymail.dev", Token: "t-tito", ExpiresAt: now.Add(time.Hour)}) // Two credentials, no default: today's refusal — and it now also says // how to stop having to choose. if _, err := f.pick(""); err == nil || !strings.Contains(err.Error(), "carlos auth default") { t.Fatalf("multi-credential refusal = %v, want the set-a-default hint", err) } // A global default resolves the ambiguity. writeConfig(t, home, `{"console": "https://tito.example"}`) c, err := f.pick("") if err != nil || c.Token != "t-tito" { t.Fatalf("pick with global default = %+v %v, want the tito credential", c, err) } // A project default overrides the global. writeConfig(t, cwd, `{"console": "https://flagship.example"}`) if c, err = f.pick(""); err != nil || c.Token != "t-flag" { t.Fatalf("pick with project default = %+v %v, want the flagship credential", c, err) } // CARLOS_CONSOLE still beats any config. t.Setenv("CARLOS_CONSOLE", "https://tito.example") if c, err = f.pick(""); err != nil || c.Token != "t-tito" { t.Fatalf("env over config = %+v %v", c, err) } t.Setenv("CARLOS_CONSOLE", "") // An explicit flag still beats everything. if c, err = f.pick("https://tito.example"); err != nil || c.Token != "t-tito" { t.Fatalf("flag over config = %+v %v", c, err) } } func TestConfigDefaultIsAuthoritative(t *testing.T) { useTempCredentials(t) cwd, err := os.Getwd() if err != nil { t.Fatal(err) } // The titogo case: the checkout is pinned to a stack this machine holds // no credential for, and the only credential is for a DIFFERENT console. // Falling back to it would quietly ship the project to the wrong // deployment — the exact mistake the default exists to prevent. f, _ := loadCredentials() f.put(credential{Console: "https://flagship.example", Address: "p@keymail.dev", Token: "t-flag", ExpiresAt: time.Now().Add(time.Hour)}) src := writeConfig(t, cwd, `{"console": "https://tito.example"}`) _, err = f.pick("") if err == nil { t.Fatal("a set default with no credential must refuse, not fall back") } for _, want := range []string{"https://tito.example", src} { if !strings.Contains(err.Error(), want) { t.Fatalf("authoritative refusal %q does not name %q", err, want) } } // A malformed config is an error even though a usable credential exists. os.WriteFile(src, []byte(`{`), 0o644) if _, err := f.pick(""); err == nil { t.Fatal("malformed config must be an error, not a skip to the credential") } } // The login chain: flag → CARLOS_CONSOLE → project config → global config → // the flagship. Login is the one verb with a built-in landing place; there // may be no credential yet, and the flagship is the product answer. func TestLoginConsoleChain(t *testing.T) { useTempCredentials(t) home := os.Getenv("HOME") cwd, err := os.Getwd() if err != nil { t.Fatal(err) } if got, err := loginConsole(""); err != nil || got != defaultConsole { t.Fatalf("bare loginConsole = %q %v, want the flagship", got, err) } writeConfig(t, home, `{"console": "https://global.example"}`) if got, err := loginConsole(""); err != nil || got != "https://global.example" { t.Fatalf("global config = %q %v", got, err) } writeConfig(t, cwd, `{"console": "https://project.example"}`) if got, err := loginConsole(""); err != nil || got != "https://project.example" { t.Fatalf("project config = %q %v", got, err) } t.Setenv("CARLOS_CONSOLE", "https://env.example") if got, err := loginConsole(""); err != nil || got != "https://env.example" { t.Fatalf("env = %q %v", got, err) } if got, err := loginConsole("https://flag.example"); err != nil || got != "https://flag.example" { t.Fatalf("flag = %q %v", got, err) } // A malformed config fails the login rather than silently logging in // to the flagship. t.Setenv("CARLOS_CONSOLE", "") os.WriteFile(filepath.Join(cwd, ".carlos", "config"), []byte(`{`), 0o644) if _, err := loginConsole(""); err == nil { t.Fatal("malformed config must fail the login, not skip to the flagship") } } // End to end: with a configured default and no -console at all, login goes // where the config points. func TestAuthLoginUsesConfiguredDefault(t *testing.T) { useTempCredentials(t) fc := newFakeConsole(t) writeConfig(t, os.Getenv("HOME"), `{"console": "`+fc.ts.URL+`"}`) var out bytes.Buffer if err := authLogin(&out, nil); err != nil { t.Fatalf("authLogin via config default: %v\n%s", err, out.String()) } f, _ := loadCredentials() if c := f.find(fc.ts.URL); c == nil || c.Token != "cli-token" { t.Fatalf("credential stored = %+v, want one for the configured console", c) } } func TestAuthDefaultVerb(t *testing.T) { useTempCredentials(t) cwd, err := os.Getwd() if err != nil { t.Fatal(err) } var out bytes.Buffer // Nothing configured: the report explains the built-in behavior rather // than printing an empty string. if err := authDefault(&out, nil); err != nil { t.Fatal(err) } for _, want := range []string{"no default", defaultConsole, "carlos auth default"} { if !strings.Contains(out.String(), want) { t.Fatalf("bare report missing %q:\n%s", want, out.String()) } } // Set the global; the value is normalized on write and read back with // its source. out.Reset() if err := authDefault(&out, []string{"https://global.example/"}); err != nil { t.Fatal(err) } out.Reset() if err := authDefault(&out, nil); err != nil { t.Fatal(err) } if !strings.Contains(out.String(), "https://global.example") || !strings.Contains(out.String(), filepath.Join(".carlos", "config")) { t.Fatalf("report after set:\n%s", out.String()) } // -project writes ./.carlos/config, which then overrides the global. out.Reset() if err := authDefault(&out, []string{"-project", "https://project.example"}); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(cwd, ".carlos", "config")); err != nil { t.Fatalf("--project did not write ./.carlos/config: %v", err) } out.Reset() if err := authDefault(&out, nil); err != nil { t.Fatal(err) } if !strings.Contains(out.String(), "https://project.example") { t.Fatalf("project default not effective:\n%s", out.String()) } // A bad URL is refused at set time, and -project with nothing to set // is a usage error, not a silent no-op. if err := authDefault(&out, []string{"ftp://nope"}); err == nil { t.Fatal("setting an invalid console must fail") } if err := authDefault(&out, []string{"-project"}); err == nil { t.Fatal("--project with no URL must be a usage error") } if err := authDefault(&out, []string{"a", "b"}); err == nil { t.Fatal("two positional args must be a usage error") } } func TestConfigAccountWalk(t *testing.T) { useTempCredentials(t) home := os.Getenv("HOME") // Nothing anywhere: no account default, no error. if a, src, cons, err := configAccount(); err != nil || a != "" || src != "" || cons != "" { t.Fatalf("bare configAccount = %q %q %q %v, want empty", a, src, cons, err) } // Only the global, and the file names no console: a pure account // default, fileConsole empty. globalPath := writeConfig(t, home, `{"account": "acme"}`) a, src, cons, err := configAccount() if err != nil || a != "acme" || src != globalPath || cons != "" { t.Fatalf("global-only = %q %q %q %v", a, src, cons, err) } // A nearer file with only a console does not stop the ACCOUNT walk. root := t.TempDir() writeConfig(t, root, `{"console": "https://outer.example"}`) nested := filepath.Join(root, "cmd", "deep") if err := os.MkdirAll(nested, 0o755); err != nil { t.Fatal(err) } t.Chdir(nested) if a, _, _, err = configAccount(); err != nil || a != "acme" { t.Fatalf("console-only level = %q %v, want the walk to continue to acme", a, err) } // The nearest account-bearing file wins and reports ITS console — // normalized, because the pairing rule compares it to a session console. nearest := writeConfig(t, nested, `{"console": "https://inner.example/", "account": "tools"}`) a, src, cons, err = configAccount() if err != nil || a != "tools" || src != nearest || cons != "https://inner.example" { t.Fatalf("nearest = %q %q %q %v", a, src, cons, err) } // Malformed config mid-walk is an error, not a skip. os.WriteFile(nearest, []byte(`not json`), 0o644) if _, _, _, err = configAccount(); err == nil { t.Fatal("malformed project config must be an error, not a skip") } } func TestConfigRewritesPreserveUnknownFields(t *testing.T) { useTempCredentials(t) dir := t.TempDir() path := filepath.Join(dir, ".carlos", "config") // A fresh saveConfigAccount writes both keys. if err := saveConfigAccount(path, "https://tito.example/", "acme", 0o755); err != nil { t.Fatal(err) } cfg, err := loadConfig(path) if err != nil || cfg.Console != "https://tito.example" || cfg.Account != "acme" { t.Fatalf("fresh save = %+v, %v", cfg, err) } // A file holding a key from the future keeps it across BOTH rewrites — // the old drop-on-rewrite behavior made every new key lossy. if err := os.WriteFile(path, []byte(`{"console":"https://tito.example","future_key":42}`), 0o644); err != nil { t.Fatal(err) } if err := saveConfigAccount(path, "https://tito.example", "tools", 0o755); err != nil { t.Fatal(err) } raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var all map[string]any if err := json.Unmarshal(raw, &all); err != nil { t.Fatal(err) } if all["future_key"] != float64(42) || all["account"] != "tools" { t.Fatalf("saveConfigAccount dropped fields: %v", all) } if err := saveConfigFile(path, "https://other.example/", 0o755); err != nil { t.Fatal(err) } raw, _ = os.ReadFile(path) all = map[string]any{} if err := json.Unmarshal(raw, &all); err != nil { t.Fatal(err) } if all["future_key"] != float64(42) || all["account"] != "tools" || all["console"] != "https://other.example" { t.Fatalf("saveConfigFile dropped fields: %v", all) } // The console is still validated on write, account or not. if err := saveConfigAccount(path, "ftp://nope", "x", 0o755); err == nil { t.Fatal("saving an invalid console must fail") } }