| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509 |
- 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)
- }
- }
|