package cli import ( "bytes" "os" "path/filepath" "strconv" "strings" "testing" ) // runCLI is a test helper that invokes Execute-equivalent with a temp config, // captured stdout/stderr, and an empty stdin (non-tty). func runCLI(t *testing.T, configPath string, args ...string) (int, string, string, error) { t.Helper() full := append([]string{"--config", configPath}, args...) var stdout, stderr bytes.Buffer stdin := bytes.NewReader(nil) code, err := ExecuteWithStreams(full, stdin, &stdout, &stderr) return code, stdout.String(), stderr.String(), err } // setupTestConfig yields a config path and ensures CC_SWITCH_CONFIG is not set. func setupTestConfig(t *testing.T) string { t.Helper() t.Setenv("CC_SWITCH_CONFIG", "") t.Setenv("XDG_CONFIG_HOME", "") dir := t.TempDir() return filepath.Join(dir, "config.yaml") } func TestListEmpty(t *testing.T) { cfg := setupTestConfig(t) code, stdout, _, err := runCLI(t, cfg, "list") if err != nil { t.Fatalf("unexpected: %v", err) } if code != 0 { t.Errorf("code = %d", code) } if !strings.Contains(stdout, "no providers configured") { t.Errorf("stdout: %q", stdout) } } func TestAddAndList(t *testing.T) { cfg := setupTestConfig(t) _, _, _, err := runCLI(t, cfg, "add", "foo", "--env", "ANTHROPIC_API_KEY=sk-ant-12345678", "--env", "ANTHROPIC_BASE_URL=https://api.anthropic.com", "--description", "official") if err != nil { t.Fatalf("add: %v", err) } _, stdout, _, err := runCLI(t, cfg, "list") if err != nil { t.Fatal(err) } if !strings.Contains(stdout, "foo") { t.Errorf("missing name: %q", stdout) } if !strings.Contains(stdout, "official") { t.Errorf("missing description: %q", stdout) } _, stdout, _, err = runCLI(t, cfg, "list", "-V") if err != nil { t.Fatal(err) } if strings.Contains(stdout, "sk-ant-12345678") { t.Errorf("raw secret leaked in -V: %q", stdout) } if !strings.Contains(stdout, "sk-a***") { t.Errorf("expected masked value: %q", stdout) } } func TestAddEnvRefNotMasked(t *testing.T) { cfg := setupTestConfig(t) if _, _, _, err := runCLI(t, cfg, "add", "byref", "--env", "ANTHROPIC_API_KEY=env:MY_KEY", ); err != nil { t.Fatal(err) } _, stdout, _, err := runCLI(t, cfg, "list", "-V") if err != nil { t.Fatal(err) } if !strings.Contains(stdout, "env:MY_KEY") { t.Errorf("env ref should be shown literal: %q", stdout) } if strings.Contains(stdout, "env:***") { t.Errorf("env ref should NOT be masked: %q", stdout) } } func TestAddDuplicate(t *testing.T) { cfg := setupTestConfig(t) _, _, _, err := runCLI(t, cfg, "add", "foo", "--env", "K=v") if err != nil { t.Fatal(err) } code, _, _, err := runCLI(t, cfg, "add", "foo", "--env", "K=v") if err == nil { t.Fatal("expected duplicate error") } if code == 0 { t.Error("duplicate should non-zero exit") } } func TestAddInvalidName(t *testing.T) { cfg := setupTestConfig(t) _, _, _, err := runCLI(t, cfg, "add", "bad name", "--env", "K=v") if err == nil { t.Fatal("expected invalid name error") } } func TestAddRequiresNonInteractiveInputs(t *testing.T) { cfg := setupTestConfig(t) _, _, _, err := runCLI(t, cfg, "add", "foo") if err == nil { t.Fatal("expected error (non-tty with no --env or template)") } } func TestAddFromTemplateNonInteractive(t *testing.T) { cfg := setupTestConfig(t) _, _, _, err := runCLI(t, cfg, "add", "or", "--from-template", "openrouter", "--non-interactive", "--env", "ANTHROPIC_AUTH_TOKEN=sk-or-xxxxxx", "--env", "ANTHROPIC_MODEL=anthropic/claude-opus-4", ) if err != nil { t.Fatalf("unexpected: %v", err) } // Template should have filled ANTHROPIC_BASE_URL with its default. _, stdout, _, err := runCLI(t, cfg, "list", "-V") if err != nil { t.Fatal(err) } if !strings.Contains(stdout, "ANTHROPIC_BASE_URL") { t.Errorf("template key missing: %q", stdout) } if !strings.Contains(stdout, "https://openrouter.ai/api/v1") { // OR's default gets masked too (it's shorter than 4... no it isn't — check masked form) // "https://openrouter.ai/api/v1" is 29 chars; masked as "http***" if !strings.Contains(stdout, "http***") { t.Errorf("expected base URL (possibly masked): %q", stdout) } } } func TestAddFromTemplateUnknown(t *testing.T) { cfg := setupTestConfig(t) _, _, stderr, err := runCLI(t, cfg, "add", "foo", "--from-template", "nonexistent", "--non-interactive") if err == nil { t.Fatal("expected unknown-template error") } if !strings.Contains(stderr, "templates list") && !strings.Contains(err.Error(), "templates list") { t.Errorf("expected hint, stderr=%q err=%v", stderr, err) } } func TestTemplatesList(t *testing.T) { cfg := setupTestConfig(t) _, stdout, _, err := runCLI(t, cfg, "templates", "list") if err != nil { t.Fatal(err) } for _, name := range []string{"anthropic-official", "openrouter", "deepseek", "moonshot", "zhipu", "custom-base"} { if !strings.Contains(stdout, name) { t.Errorf("missing template %q in output: %q", name, stdout) } } } func TestTemplatesShow(t *testing.T) { cfg := setupTestConfig(t) _, stdout, _, err := runCLI(t, cfg, "templates", "show", "openrouter") if err != nil { t.Fatal(err) } if !strings.Contains(stdout, "ANTHROPIC_BASE_URL") { t.Errorf("missing env key in show: %q", stdout) } if !strings.Contains(stdout, "openrouter.ai") { t.Errorf("missing default in show: %q", stdout) } } func TestTemplatesShowMissing(t *testing.T) { cfg := setupTestConfig(t) _, _, _, err := runCLI(t, cfg, "templates", "show", "nonexistent") if err == nil { t.Fatal("expected missing-template error") } } func TestEditAddAndRemoveKey(t *testing.T) { cfg := setupTestConfig(t) if _, _, _, err := runCLI(t, cfg, "add", "foo", "--env", "ANTHROPIC_API_KEY=sk-xxxxxx", "--env", "ANTHROPIC_BASE_URL=https://one"); err != nil { t.Fatal(err) } if _, _, _, err := runCLI(t, cfg, "edit", "foo", "--env", "ANTHROPIC_MODEL=claude-opus", "--remove-env", "ANTHROPIC_BASE_URL", "--description", "new desc"); err != nil { t.Fatal(err) } _, stdout, _, err := runCLI(t, cfg, "list", "-V") if err != nil { t.Fatal(err) } if !strings.Contains(stdout, "ANTHROPIC_MODEL") { t.Errorf("model not added: %q", stdout) } if strings.Contains(stdout, "ANTHROPIC_BASE_URL") { t.Errorf("base URL not removed: %q", stdout) } if !strings.Contains(stdout, "new desc") { t.Errorf("description not updated: %q", stdout) } } func TestRemoveClearsDefault(t *testing.T) { cfg := setupTestConfig(t) _, _, _, _ = runCLI(t, cfg, "add", "foo", "--env", "K=v") _, _, _, _ = runCLI(t, cfg, "config", "set", "default", "foo") _, stdout, _, err := runCLI(t, cfg, "config", "get", "default") if err != nil || strings.TrimSpace(stdout) != "foo" { t.Fatalf("default not set: stdout=%q err=%v", stdout, err) } if _, _, _, err := runCLI(t, cfg, "remove", "foo"); err != nil { t.Fatal(err) } _, stdout, _, _ = runCLI(t, cfg, "config", "get", "default") if strings.TrimSpace(stdout) != "" { t.Errorf("default not cleared: %q", stdout) } } func TestConfigSetInvalidKey(t *testing.T) { cfg := setupTestConfig(t) _, _, _, err := runCLI(t, cfg, "config", "set", "bogus", "x") if err == nil { t.Fatal("expected error for unknown config key") } } func TestConfigSetDefaultMissingProvider(t *testing.T) { cfg := setupTestConfig(t) _, _, _, err := runCLI(t, cfg, "config", "set", "default", "nope") if err == nil { t.Fatal("expected error") } } func TestUseRejectsUnknownProvider(t *testing.T) { cfg := setupTestConfig(t) _, _, _, _ = runCLI(t, cfg, "add", "foo", "--env", "K=v") _, _, _, err := runCLI(t, cfg, "use", "bar") if err == nil { t.Fatal("expected unknown provider error") } } func TestUseNonTTYNoDefault(t *testing.T) { cfg := setupTestConfig(t) _, _, _, _ = runCLI(t, cfg, "add", "foo", "--env", "K=v") _, _, _, err := runCLI(t, cfg, "use") if err == nil { t.Fatal("expected non-tty-no-default error") } } func TestUseLaunchesWithFakeClaude(t *testing.T) { cfg := setupTestConfig(t) dir := t.TempDir() // Fake claude: print FOO to a file, exit 0. fakeClaude := filepath.Join(dir, "claude") outFile := filepath.Join(dir, "out") script := "#!/bin/sh\nprintf '%s' \"$FOO\" > " + outFile + "\nexit 0\n" if err := os.WriteFile(fakeClaude, []byte(script), 0o755); err != nil { t.Fatal(err) } if _, _, _, err := runCLI(t, cfg, "add", "ff", "--env", "FOO=bar"); err != nil { t.Fatal(err) } if _, _, _, err := runCLI(t, cfg, "config", "set", "claude-path", fakeClaude); err != nil { t.Fatal(err) } code, _, _, err := runCLI(t, cfg, "use", "ff") if err != nil { t.Fatalf("use: %v", err) } if code != 0 { t.Errorf("exit code = %d", code) } b, err := os.ReadFile(outFile) if err != nil { t.Fatal(err) } if string(b) != "bar" { t.Errorf("env not injected: %q", string(b)) } } func TestUseEnvRefMissingDoesNotLaunch(t *testing.T) { cfg := setupTestConfig(t) dir := t.TempDir() fakeClaude := filepath.Join(dir, "claude") sentinel := filepath.Join(dir, "ran") script := "#!/bin/sh\ntouch " + sentinel + "\nexit 0\n" if err := os.WriteFile(fakeClaude, []byte(script), 0o755); err != nil { t.Fatal(err) } if _, _, _, err := runCLI(t, cfg, "add", "needs-ref", "--env", "ANTHROPIC_API_KEY=env:MY_MISSING_KEY"); err != nil { t.Fatal(err) } if _, _, _, err := runCLI(t, cfg, "config", "set", "claude-path", fakeClaude); err != nil { t.Fatal(err) } // Ensure MY_MISSING_KEY is NOT set. t.Setenv("MY_MISSING_KEY", "") os.Unsetenv("MY_MISSING_KEY") _, _, _, err := runCLI(t, cfg, "use", "needs-ref") if err == nil { t.Fatal("expected error for missing ref") } if _, statErr := os.Stat(sentinel); statErr == nil { t.Error("claude should not have been launched") } } func TestUseExitCodePassthrough(t *testing.T) { cfg := setupTestConfig(t) dir := t.TempDir() fakeClaude := filepath.Join(dir, "claude") script := "#!/bin/sh\nexit 42\n" if err := os.WriteFile(fakeClaude, []byte(script), 0o755); err != nil { t.Fatal(err) } _, _, _, _ = runCLI(t, cfg, "add", "x", "--env", "FOO=b") _, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude) code, _, _, _ := runCLI(t, cfg, "use", "x") if code != 42 { t.Errorf("exit code = %d, want 42", code) } } // writeFakeClaude creates a shell script that prints each listed env var's // value (one "KEY=VAL" per line) to outFile and exits with the given code. func writeFakeClaude(t *testing.T, dir string, keys []string, outFile string, exitCode int) string { t.Helper() path := filepath.Join(dir, "claude") var sb strings.Builder sb.WriteString("#!/bin/sh\n") sb.WriteString(": > " + outFile + "\n") for _, k := range keys { // Print KEY=VAL if set (use `set` so unset yields no output for that key). sb.WriteString("if [ \"${" + k + "+x}\" = x ]; then printf '%s=%s\\n' \"" + k + "\" \"${" + k + "}\" >> " + outFile + "; fi\n") } sb.WriteString("exit " + strconv.Itoa(exitCode) + "\n") if err := os.WriteFile(path, []byte(sb.String()), 0o755); err != nil { t.Fatal(err) } return path } func TestUseCleansParentEnvUnion(t *testing.T) { cfg := setupTestConfig(t) dir := t.TempDir() outFile := filepath.Join(dir, "out") fakeClaude := writeFakeClaude(t, dir, []string{"ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"}, outFile, 0) // provider A owns ANTHROPIC_API_KEY, provider B owns ANTHROPIC_BASE_URL. // Union includes both. We USE A: B's key should be stripped from env. _, _, _, _ = runCLI(t, cfg, "add", "a", "--env", "ANTHROPIC_API_KEY=sk-new") _, _, _, _ = runCLI(t, cfg, "add", "b", "--env", "ANTHROPIC_BASE_URL=https://b") _, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude) // Simulate a polluted parent shell. t.Setenv("ANTHROPIC_API_KEY", "stale-should-be-overridden") t.Setenv("ANTHROPIC_BASE_URL", "stale-should-be-cleaned") if _, _, _, err := runCLI(t, cfg, "use", "a"); err != nil { t.Fatalf("use: %v", err) } b, err := os.ReadFile(outFile) if err != nil { t.Fatal(err) } got := string(b) if !strings.Contains(got, "ANTHROPIC_API_KEY=sk-new") { t.Errorf("missing injected key: %q", got) } if strings.Contains(got, "stale") { t.Errorf("stale parent value leaked: %q", got) } if strings.Contains(got, "ANTHROPIC_BASE_URL") { t.Errorf("union key should have been cleaned: %q", got) } } func TestUseLiteralValueNotShellExpanded(t *testing.T) { cfg := setupTestConfig(t) dir := t.TempDir() outFile := filepath.Join(dir, "out") fakeClaude := writeFakeClaude(t, dir, []string{"WEIRD"}, outFile, 0) _, _, _, _ = runCLI(t, cfg, "add", "x", "--env", "WEIRD=$HOME/api") _, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude) if _, _, _, err := runCLI(t, cfg, "use", "x"); err != nil { t.Fatal(err) } b, _ := os.ReadFile(outFile) if !strings.Contains(string(b), "WEIRD=$HOME/api") { t.Errorf("value should be literal: %q", string(b)) } } func TestUseEnvRefResolvesFromParent(t *testing.T) { cfg := setupTestConfig(t) dir := t.TempDir() outFile := filepath.Join(dir, "out") fakeClaude := writeFakeClaude(t, dir, []string{"ANTHROPIC_API_KEY"}, outFile, 0) _, _, _, _ = runCLI(t, cfg, "add", "ref", "--env", "ANTHROPIC_API_KEY=env:MY_EXTERNAL_KEY") _, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude) t.Setenv("MY_EXTERNAL_KEY", "sk-from-parent") if _, _, _, err := runCLI(t, cfg, "use", "ref"); err != nil { t.Fatalf("use: %v", err) } b, _ := os.ReadFile(outFile) if !strings.Contains(string(b), "ANTHROPIC_API_KEY=sk-from-parent") { t.Errorf("ref not resolved: %q", string(b)) } } func TestUseEnvRefResolvesBeforeCleanup(t *testing.T) { // Critical edge: provider B says `ANTHROPIC_API_KEY=env:ANTHROPIC_API_KEY`. // The referenced VAR is ALSO in the union (provider A also defines that // key). We must snapshot parent env before cleanup, so the ref still // resolves to the parent shell's value. cfg := setupTestConfig(t) dir := t.TempDir() outFile := filepath.Join(dir, "out") fakeClaude := writeFakeClaude(t, dir, []string{"ANTHROPIC_API_KEY"}, outFile, 0) _, _, _, _ = runCLI(t, cfg, "add", "a", "--env", "ANTHROPIC_API_KEY=sk-a") _, _, _, _ = runCLI(t, cfg, "add", "b", "--env", "ANTHROPIC_API_KEY=env:ANTHROPIC_API_KEY") _, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude) t.Setenv("ANTHROPIC_API_KEY", "sk-parent") if _, _, _, err := runCLI(t, cfg, "use", "b"); err != nil { t.Fatalf("use: %v", err) } b, _ := os.ReadFile(outFile) if !strings.Contains(string(b), "ANTHROPIC_API_KEY=sk-parent") { t.Errorf("snapshot-before-cleanup broke: %q", string(b)) } } func TestPermissionWarning(t *testing.T) { cfg := setupTestConfig(t) if _, _, _, err := runCLI(t, cfg, "add", "x", "--env", "K=v"); err != nil { t.Fatal(err) } if err := os.Chmod(cfg, 0o644); err != nil { t.Fatal(err) } _, _, stderr, err := runCLI(t, cfg, "list") if err != nil { t.Fatal(err) } if !strings.Contains(stderr, "chmod 600") { t.Errorf("expected permission warning; stderr=%q", stderr) } } func TestVersionCmd(t *testing.T) { cfg := setupTestConfig(t) _, stdout, _, err := runCLI(t, cfg, "version") if err != nil { t.Fatal(err) } if !strings.HasPrefix(stdout, "cc-switch ") { t.Errorf("unexpected: %q", stdout) } }