cli_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. package cli
  2. import (
  3. "bytes"
  4. "os"
  5. "path/filepath"
  6. "strconv"
  7. "strings"
  8. "testing"
  9. )
  10. // runCLI is a test helper that invokes Execute-equivalent with a temp config,
  11. // captured stdout/stderr, and an empty stdin (non-tty).
  12. func runCLI(t *testing.T, configPath string, args ...string) (int, string, string, error) {
  13. t.Helper()
  14. full := append([]string{"--config", configPath}, args...)
  15. var stdout, stderr bytes.Buffer
  16. stdin := bytes.NewReader(nil)
  17. code, err := ExecuteWithStreams(full, stdin, &stdout, &stderr)
  18. return code, stdout.String(), stderr.String(), err
  19. }
  20. // setupTestConfig yields a config path and ensures CC_SWITCH_CONFIG is not set.
  21. func setupTestConfig(t *testing.T) string {
  22. t.Helper()
  23. t.Setenv("CC_SWITCH_CONFIG", "")
  24. t.Setenv("XDG_CONFIG_HOME", "")
  25. dir := t.TempDir()
  26. return filepath.Join(dir, "config.yaml")
  27. }
  28. func TestListEmpty(t *testing.T) {
  29. cfg := setupTestConfig(t)
  30. code, stdout, _, err := runCLI(t, cfg, "list")
  31. if err != nil {
  32. t.Fatalf("unexpected: %v", err)
  33. }
  34. if code != 0 {
  35. t.Errorf("code = %d", code)
  36. }
  37. if !strings.Contains(stdout, "no providers configured") {
  38. t.Errorf("stdout: %q", stdout)
  39. }
  40. }
  41. func TestAddAndList(t *testing.T) {
  42. cfg := setupTestConfig(t)
  43. _, _, _, err := runCLI(t, cfg,
  44. "add", "foo",
  45. "--env", "ANTHROPIC_API_KEY=sk-ant-12345678",
  46. "--env", "ANTHROPIC_BASE_URL=https://api.anthropic.com",
  47. "--description", "official")
  48. if err != nil {
  49. t.Fatalf("add: %v", err)
  50. }
  51. _, stdout, _, err := runCLI(t, cfg, "list")
  52. if err != nil {
  53. t.Fatal(err)
  54. }
  55. if !strings.Contains(stdout, "foo") {
  56. t.Errorf("missing name: %q", stdout)
  57. }
  58. if !strings.Contains(stdout, "official") {
  59. t.Errorf("missing description: %q", stdout)
  60. }
  61. _, stdout, _, err = runCLI(t, cfg, "list", "-V")
  62. if err != nil {
  63. t.Fatal(err)
  64. }
  65. if strings.Contains(stdout, "sk-ant-12345678") {
  66. t.Errorf("raw secret leaked in -V: %q", stdout)
  67. }
  68. if !strings.Contains(stdout, "sk-a***") {
  69. t.Errorf("expected masked value: %q", stdout)
  70. }
  71. }
  72. func TestAddEnvRefNotMasked(t *testing.T) {
  73. cfg := setupTestConfig(t)
  74. if _, _, _, err := runCLI(t, cfg,
  75. "add", "byref",
  76. "--env", "ANTHROPIC_API_KEY=env:MY_KEY",
  77. ); err != nil {
  78. t.Fatal(err)
  79. }
  80. _, stdout, _, err := runCLI(t, cfg, "list", "-V")
  81. if err != nil {
  82. t.Fatal(err)
  83. }
  84. if !strings.Contains(stdout, "env:MY_KEY") {
  85. t.Errorf("env ref should be shown literal: %q", stdout)
  86. }
  87. if strings.Contains(stdout, "env:***") {
  88. t.Errorf("env ref should NOT be masked: %q", stdout)
  89. }
  90. }
  91. func TestAddDuplicate(t *testing.T) {
  92. cfg := setupTestConfig(t)
  93. _, _, _, err := runCLI(t, cfg, "add", "foo", "--env", "K=v")
  94. if err != nil {
  95. t.Fatal(err)
  96. }
  97. code, _, _, err := runCLI(t, cfg, "add", "foo", "--env", "K=v")
  98. if err == nil {
  99. t.Fatal("expected duplicate error")
  100. }
  101. if code == 0 {
  102. t.Error("duplicate should non-zero exit")
  103. }
  104. }
  105. func TestAddInvalidName(t *testing.T) {
  106. cfg := setupTestConfig(t)
  107. _, _, _, err := runCLI(t, cfg, "add", "bad name", "--env", "K=v")
  108. if err == nil {
  109. t.Fatal("expected invalid name error")
  110. }
  111. }
  112. func TestAddRequiresNonInteractiveInputs(t *testing.T) {
  113. cfg := setupTestConfig(t)
  114. _, _, _, err := runCLI(t, cfg, "add", "foo")
  115. if err == nil {
  116. t.Fatal("expected error (non-tty with no --env or template)")
  117. }
  118. }
  119. func TestAddFromTemplateNonInteractive(t *testing.T) {
  120. cfg := setupTestConfig(t)
  121. _, _, _, err := runCLI(t, cfg,
  122. "add", "or",
  123. "--from-template", "openrouter",
  124. "--non-interactive",
  125. "--env", "ANTHROPIC_AUTH_TOKEN=sk-or-xxxxxx",
  126. "--env", "ANTHROPIC_MODEL=anthropic/claude-opus-4",
  127. )
  128. if err != nil {
  129. t.Fatalf("unexpected: %v", err)
  130. }
  131. // Template should have filled ANTHROPIC_BASE_URL with its default.
  132. _, stdout, _, err := runCLI(t, cfg, "list", "-V")
  133. if err != nil {
  134. t.Fatal(err)
  135. }
  136. if !strings.Contains(stdout, "ANTHROPIC_BASE_URL") {
  137. t.Errorf("template key missing: %q", stdout)
  138. }
  139. if !strings.Contains(stdout, "https://openrouter.ai/api/v1") {
  140. // OR's default gets masked too (it's shorter than 4... no it isn't — check masked form)
  141. // "https://openrouter.ai/api/v1" is 29 chars; masked as "http***"
  142. if !strings.Contains(stdout, "http***") {
  143. t.Errorf("expected base URL (possibly masked): %q", stdout)
  144. }
  145. }
  146. }
  147. func TestAddFromTemplateUnknown(t *testing.T) {
  148. cfg := setupTestConfig(t)
  149. _, _, stderr, err := runCLI(t, cfg,
  150. "add", "foo", "--from-template", "nonexistent", "--non-interactive")
  151. if err == nil {
  152. t.Fatal("expected unknown-template error")
  153. }
  154. if !strings.Contains(stderr, "templates list") && !strings.Contains(err.Error(), "templates list") {
  155. t.Errorf("expected hint, stderr=%q err=%v", stderr, err)
  156. }
  157. }
  158. func TestTemplatesList(t *testing.T) {
  159. cfg := setupTestConfig(t)
  160. _, stdout, _, err := runCLI(t, cfg, "templates", "list")
  161. if err != nil {
  162. t.Fatal(err)
  163. }
  164. for _, name := range []string{"anthropic-official", "openrouter", "deepseek", "moonshot", "zhipu", "custom-base"} {
  165. if !strings.Contains(stdout, name) {
  166. t.Errorf("missing template %q in output: %q", name, stdout)
  167. }
  168. }
  169. }
  170. func TestTemplatesShow(t *testing.T) {
  171. cfg := setupTestConfig(t)
  172. _, stdout, _, err := runCLI(t, cfg, "templates", "show", "openrouter")
  173. if err != nil {
  174. t.Fatal(err)
  175. }
  176. if !strings.Contains(stdout, "ANTHROPIC_BASE_URL") {
  177. t.Errorf("missing env key in show: %q", stdout)
  178. }
  179. if !strings.Contains(stdout, "openrouter.ai") {
  180. t.Errorf("missing default in show: %q", stdout)
  181. }
  182. }
  183. func TestTemplatesShowMissing(t *testing.T) {
  184. cfg := setupTestConfig(t)
  185. _, _, _, err := runCLI(t, cfg, "templates", "show", "nonexistent")
  186. if err == nil {
  187. t.Fatal("expected missing-template error")
  188. }
  189. }
  190. func TestEditAddAndRemoveKey(t *testing.T) {
  191. cfg := setupTestConfig(t)
  192. if _, _, _, err := runCLI(t, cfg, "add", "foo",
  193. "--env", "ANTHROPIC_API_KEY=sk-xxxxxx",
  194. "--env", "ANTHROPIC_BASE_URL=https://one"); err != nil {
  195. t.Fatal(err)
  196. }
  197. if _, _, _, err := runCLI(t, cfg, "edit", "foo",
  198. "--env", "ANTHROPIC_MODEL=claude-opus",
  199. "--remove-env", "ANTHROPIC_BASE_URL",
  200. "--description", "new desc"); err != nil {
  201. t.Fatal(err)
  202. }
  203. _, stdout, _, err := runCLI(t, cfg, "list", "-V")
  204. if err != nil {
  205. t.Fatal(err)
  206. }
  207. if !strings.Contains(stdout, "ANTHROPIC_MODEL") {
  208. t.Errorf("model not added: %q", stdout)
  209. }
  210. if strings.Contains(stdout, "ANTHROPIC_BASE_URL") {
  211. t.Errorf("base URL not removed: %q", stdout)
  212. }
  213. if !strings.Contains(stdout, "new desc") {
  214. t.Errorf("description not updated: %q", stdout)
  215. }
  216. }
  217. func TestRemoveClearsDefault(t *testing.T) {
  218. cfg := setupTestConfig(t)
  219. _, _, _, _ = runCLI(t, cfg, "add", "foo", "--env", "K=v")
  220. _, _, _, _ = runCLI(t, cfg, "config", "set", "default", "foo")
  221. _, stdout, _, err := runCLI(t, cfg, "config", "get", "default")
  222. if err != nil || strings.TrimSpace(stdout) != "foo" {
  223. t.Fatalf("default not set: stdout=%q err=%v", stdout, err)
  224. }
  225. if _, _, _, err := runCLI(t, cfg, "remove", "foo"); err != nil {
  226. t.Fatal(err)
  227. }
  228. _, stdout, _, _ = runCLI(t, cfg, "config", "get", "default")
  229. if strings.TrimSpace(stdout) != "" {
  230. t.Errorf("default not cleared: %q", stdout)
  231. }
  232. }
  233. func TestConfigSetInvalidKey(t *testing.T) {
  234. cfg := setupTestConfig(t)
  235. _, _, _, err := runCLI(t, cfg, "config", "set", "bogus", "x")
  236. if err == nil {
  237. t.Fatal("expected error for unknown config key")
  238. }
  239. }
  240. func TestConfigSetDefaultMissingProvider(t *testing.T) {
  241. cfg := setupTestConfig(t)
  242. _, _, _, err := runCLI(t, cfg, "config", "set", "default", "nope")
  243. if err == nil {
  244. t.Fatal("expected error")
  245. }
  246. }
  247. func TestUseRejectsUnknownProvider(t *testing.T) {
  248. cfg := setupTestConfig(t)
  249. _, _, _, _ = runCLI(t, cfg, "add", "foo", "--env", "K=v")
  250. _, _, _, err := runCLI(t, cfg, "use", "bar")
  251. if err == nil {
  252. t.Fatal("expected unknown provider error")
  253. }
  254. }
  255. func TestUseNonTTYNoDefault(t *testing.T) {
  256. cfg := setupTestConfig(t)
  257. _, _, _, _ = runCLI(t, cfg, "add", "foo", "--env", "K=v")
  258. _, _, _, err := runCLI(t, cfg, "use")
  259. if err == nil {
  260. t.Fatal("expected non-tty-no-default error")
  261. }
  262. }
  263. func TestUseLaunchesWithFakeClaude(t *testing.T) {
  264. cfg := setupTestConfig(t)
  265. dir := t.TempDir()
  266. // Fake claude: print FOO to a file, exit 0.
  267. fakeClaude := filepath.Join(dir, "claude")
  268. outFile := filepath.Join(dir, "out")
  269. script := "#!/bin/sh\nprintf '%s' \"$FOO\" > " + outFile + "\nexit 0\n"
  270. if err := os.WriteFile(fakeClaude, []byte(script), 0o755); err != nil {
  271. t.Fatal(err)
  272. }
  273. if _, _, _, err := runCLI(t, cfg, "add", "ff", "--env", "FOO=bar"); err != nil {
  274. t.Fatal(err)
  275. }
  276. if _, _, _, err := runCLI(t, cfg, "config", "set", "claude-path", fakeClaude); err != nil {
  277. t.Fatal(err)
  278. }
  279. code, _, _, err := runCLI(t, cfg, "use", "ff")
  280. if err != nil {
  281. t.Fatalf("use: %v", err)
  282. }
  283. if code != 0 {
  284. t.Errorf("exit code = %d", code)
  285. }
  286. b, err := os.ReadFile(outFile)
  287. if err != nil {
  288. t.Fatal(err)
  289. }
  290. if string(b) != "bar" {
  291. t.Errorf("env not injected: %q", string(b))
  292. }
  293. }
  294. func TestUseEnvRefMissingDoesNotLaunch(t *testing.T) {
  295. cfg := setupTestConfig(t)
  296. dir := t.TempDir()
  297. fakeClaude := filepath.Join(dir, "claude")
  298. sentinel := filepath.Join(dir, "ran")
  299. script := "#!/bin/sh\ntouch " + sentinel + "\nexit 0\n"
  300. if err := os.WriteFile(fakeClaude, []byte(script), 0o755); err != nil {
  301. t.Fatal(err)
  302. }
  303. if _, _, _, err := runCLI(t, cfg, "add", "needs-ref",
  304. "--env", "ANTHROPIC_API_KEY=env:MY_MISSING_KEY"); err != nil {
  305. t.Fatal(err)
  306. }
  307. if _, _, _, err := runCLI(t, cfg, "config", "set", "claude-path", fakeClaude); err != nil {
  308. t.Fatal(err)
  309. }
  310. // Ensure MY_MISSING_KEY is NOT set.
  311. t.Setenv("MY_MISSING_KEY", "")
  312. os.Unsetenv("MY_MISSING_KEY")
  313. _, _, _, err := runCLI(t, cfg, "use", "needs-ref")
  314. if err == nil {
  315. t.Fatal("expected error for missing ref")
  316. }
  317. if _, statErr := os.Stat(sentinel); statErr == nil {
  318. t.Error("claude should not have been launched")
  319. }
  320. }
  321. func TestUseExitCodePassthrough(t *testing.T) {
  322. cfg := setupTestConfig(t)
  323. dir := t.TempDir()
  324. fakeClaude := filepath.Join(dir, "claude")
  325. script := "#!/bin/sh\nexit 42\n"
  326. if err := os.WriteFile(fakeClaude, []byte(script), 0o755); err != nil {
  327. t.Fatal(err)
  328. }
  329. _, _, _, _ = runCLI(t, cfg, "add", "x", "--env", "FOO=b")
  330. _, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude)
  331. code, _, _, _ := runCLI(t, cfg, "use", "x")
  332. if code != 42 {
  333. t.Errorf("exit code = %d, want 42", code)
  334. }
  335. }
  336. // writeFakeClaude creates a shell script that prints each listed env var's
  337. // value (one "KEY=VAL" per line) to outFile and exits with the given code.
  338. func writeFakeClaude(t *testing.T, dir string, keys []string, outFile string, exitCode int) string {
  339. t.Helper()
  340. path := filepath.Join(dir, "claude")
  341. var sb strings.Builder
  342. sb.WriteString("#!/bin/sh\n")
  343. sb.WriteString(": > " + outFile + "\n")
  344. for _, k := range keys {
  345. // Print KEY=VAL if set (use `set` so unset yields no output for that key).
  346. sb.WriteString("if [ \"${" + k + "+x}\" = x ]; then printf '%s=%s\\n' \"" + k + "\" \"${" + k + "}\" >> " + outFile + "; fi\n")
  347. }
  348. sb.WriteString("exit " + strconv.Itoa(exitCode) + "\n")
  349. if err := os.WriteFile(path, []byte(sb.String()), 0o755); err != nil {
  350. t.Fatal(err)
  351. }
  352. return path
  353. }
  354. func TestUseCleansParentEnvUnion(t *testing.T) {
  355. cfg := setupTestConfig(t)
  356. dir := t.TempDir()
  357. outFile := filepath.Join(dir, "out")
  358. fakeClaude := writeFakeClaude(t, dir, []string{"ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"}, outFile, 0)
  359. // provider A owns ANTHROPIC_API_KEY, provider B owns ANTHROPIC_BASE_URL.
  360. // Union includes both. We USE A: B's key should be stripped from env.
  361. _, _, _, _ = runCLI(t, cfg, "add", "a", "--env", "ANTHROPIC_API_KEY=sk-new")
  362. _, _, _, _ = runCLI(t, cfg, "add", "b", "--env", "ANTHROPIC_BASE_URL=https://b")
  363. _, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude)
  364. // Simulate a polluted parent shell.
  365. t.Setenv("ANTHROPIC_API_KEY", "stale-should-be-overridden")
  366. t.Setenv("ANTHROPIC_BASE_URL", "stale-should-be-cleaned")
  367. if _, _, _, err := runCLI(t, cfg, "use", "a"); err != nil {
  368. t.Fatalf("use: %v", err)
  369. }
  370. b, err := os.ReadFile(outFile)
  371. if err != nil {
  372. t.Fatal(err)
  373. }
  374. got := string(b)
  375. if !strings.Contains(got, "ANTHROPIC_API_KEY=sk-new") {
  376. t.Errorf("missing injected key: %q", got)
  377. }
  378. if strings.Contains(got, "stale") {
  379. t.Errorf("stale parent value leaked: %q", got)
  380. }
  381. if strings.Contains(got, "ANTHROPIC_BASE_URL") {
  382. t.Errorf("union key should have been cleaned: %q", got)
  383. }
  384. }
  385. func TestUseLiteralValueNotShellExpanded(t *testing.T) {
  386. cfg := setupTestConfig(t)
  387. dir := t.TempDir()
  388. outFile := filepath.Join(dir, "out")
  389. fakeClaude := writeFakeClaude(t, dir, []string{"WEIRD"}, outFile, 0)
  390. _, _, _, _ = runCLI(t, cfg, "add", "x", "--env", "WEIRD=$HOME/api")
  391. _, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude)
  392. if _, _, _, err := runCLI(t, cfg, "use", "x"); err != nil {
  393. t.Fatal(err)
  394. }
  395. b, _ := os.ReadFile(outFile)
  396. if !strings.Contains(string(b), "WEIRD=$HOME/api") {
  397. t.Errorf("value should be literal: %q", string(b))
  398. }
  399. }
  400. func TestUseEnvRefResolvesFromParent(t *testing.T) {
  401. cfg := setupTestConfig(t)
  402. dir := t.TempDir()
  403. outFile := filepath.Join(dir, "out")
  404. fakeClaude := writeFakeClaude(t, dir, []string{"ANTHROPIC_API_KEY"}, outFile, 0)
  405. _, _, _, _ = runCLI(t, cfg, "add", "ref", "--env", "ANTHROPIC_API_KEY=env:MY_EXTERNAL_KEY")
  406. _, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude)
  407. t.Setenv("MY_EXTERNAL_KEY", "sk-from-parent")
  408. if _, _, _, err := runCLI(t, cfg, "use", "ref"); err != nil {
  409. t.Fatalf("use: %v", err)
  410. }
  411. b, _ := os.ReadFile(outFile)
  412. if !strings.Contains(string(b), "ANTHROPIC_API_KEY=sk-from-parent") {
  413. t.Errorf("ref not resolved: %q", string(b))
  414. }
  415. }
  416. func TestUseEnvRefResolvesBeforeCleanup(t *testing.T) {
  417. // Critical edge: provider B says `ANTHROPIC_API_KEY=env:ANTHROPIC_API_KEY`.
  418. // The referenced VAR is ALSO in the union (provider A also defines that
  419. // key). We must snapshot parent env before cleanup, so the ref still
  420. // resolves to the parent shell's value.
  421. cfg := setupTestConfig(t)
  422. dir := t.TempDir()
  423. outFile := filepath.Join(dir, "out")
  424. fakeClaude := writeFakeClaude(t, dir, []string{"ANTHROPIC_API_KEY"}, outFile, 0)
  425. _, _, _, _ = runCLI(t, cfg, "add", "a", "--env", "ANTHROPIC_API_KEY=sk-a")
  426. _, _, _, _ = runCLI(t, cfg, "add", "b", "--env", "ANTHROPIC_API_KEY=env:ANTHROPIC_API_KEY")
  427. _, _, _, _ = runCLI(t, cfg, "config", "set", "claude-path", fakeClaude)
  428. t.Setenv("ANTHROPIC_API_KEY", "sk-parent")
  429. if _, _, _, err := runCLI(t, cfg, "use", "b"); err != nil {
  430. t.Fatalf("use: %v", err)
  431. }
  432. b, _ := os.ReadFile(outFile)
  433. if !strings.Contains(string(b), "ANTHROPIC_API_KEY=sk-parent") {
  434. t.Errorf("snapshot-before-cleanup broke: %q", string(b))
  435. }
  436. }
  437. func TestPermissionWarning(t *testing.T) {
  438. cfg := setupTestConfig(t)
  439. if _, _, _, err := runCLI(t, cfg, "add", "x", "--env", "K=v"); err != nil {
  440. t.Fatal(err)
  441. }
  442. if err := os.Chmod(cfg, 0o644); err != nil {
  443. t.Fatal(err)
  444. }
  445. _, _, stderr, err := runCLI(t, cfg, "list")
  446. if err != nil {
  447. t.Fatal(err)
  448. }
  449. if !strings.Contains(stderr, "chmod 600") {
  450. t.Errorf("expected permission warning; stderr=%q", stderr)
  451. }
  452. }
  453. func TestVersionCmd(t *testing.T) {
  454. cfg := setupTestConfig(t)
  455. _, stdout, _, err := runCLI(t, cfg, "version")
  456. if err != nil {
  457. t.Fatal(err)
  458. }
  459. if !strings.HasPrefix(stdout, "cc-switch ") {
  460. t.Errorf("unexpected: %q", stdout)
  461. }
  462. }