package main import ( "bytes" "context" "errors" "io" "net/http" "os" "path/filepath" "strings" "testing" "github.com/johannSo/codey/internal/config" "github.com/johannSo/codey/internal/llm" ) // fakeHome writes a credentials file with a token that is not expired. func fakeHome(t *testing.T) string { t.Helper() home := t.TempDir() dir := filepath.Join(home, ".claude") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } // expiresAt is 2035, comfortably in the future. body := `{"claudeAiOauth":{"accessToken":"file-token","refreshToken":"r", "expiresAt":2058430000000,"refreshTokenExpiresAt":2058430000000, "scopes":["user:inference"],"subscriptionType":"team"}}` if err := os.WriteFile(filepath.Join(dir, ".credentials.json"), []byte(body), 0o600); err != nil { t.Fatal(err) } return home } // tripwireHome writes a credentials file that is malformed JSON: a // deliberate landmine, not a fixture. If resolveClaudeCode ever reads it // when it should not have — the login was not asked for, or the endpoint // disqualifies it — claudecode.Load returns a parse error and the test // surfaces that loudly, rather than passing on the unobservable (and here // false) assumption that the file went untouched. func tripwireHome(t *testing.T) string { t.Helper() home := t.TempDir() dir := filepath.Join(home, ".claude") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, ".credentials.json"), []byte("{not json"), 0o600); err != nil { t.Fatal(err) } return home } // cleanEnv keeps the developer's own shell out of a test about where the // credential comes from: an exported ANTHROPIC_API_KEY would otherwise be // the credential every one of these tests found. func cleanEnv(t *testing.T) { t.Helper() t.Setenv("ANTHROPIC_API_KEY", "") t.Setenv("ANTHROPIC_AUTH_TOKEN", "") t.Setenv("ANTHROPIC_BASE_URL", "") } // The order the switches are read in. The refusal outranks the request, and // with neither flag the config decides, and with no config the answer is no. func TestClaudeCodeAuthPrecedence(t *testing.T) { on := config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOn} off := config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOff} cases := []struct { name string refuse, request bool cfg config.Config want bool }{ {"nothing said: off", false, false, config.Config{}, false}, {"config on", false, false, on, true}, {"config off", false, false, off, false}, {"flag on, no config", false, true, config.Config{}, true}, {"flag on beats config off", false, true, off, true}, {"-no beats config on", true, false, on, false}, {"-no beats the flag", true, true, config.Config{}, false}, {"-no beats the flag and config on", true, true, on, false}, {"an unknown word is not on", false, false, config.Config{ClaudeCodeAuth: "yes"}, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := claudeCodeAllowed(tc.refuse, tc.request, tc.cfg); got != tc.want { t.Errorf("claudeCodeAllowed(-no=%v, -claude-code-auth=%v, %q) = %v, want %v", tc.refuse, tc.request, tc.cfg.ClaudeCodeAuth, got, tc.want) } }) } } func noCredentials(baseURL string) *llm.Client { c := llm.New(llm.WithBaseURL(baseURL)) c.ClearCredentialsForTest() return c } // The gate, end to end: what the flags and the config say goes through // claudeCodeAllowed into resolveClaudeCode, as it does in run. Every case // that must not borrow is run against the tripwire, so "it did not use the // login" also proves "it did not open the file". func TestClaudeCodeIsBorrowedOnlyWhenAskedFor(t *testing.T) { on := config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOn} cases := []struct { name string refuse, request bool cfg config.Config base string borrow bool }{ {"off by default", false, false, config.Config{}, llm.DefaultBaseURL, false}, {"off when the config says off", false, false, config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOff}, llm.DefaultBaseURL, false}, {"on with the config", false, false, on, llm.DefaultBaseURL, true}, {"on with the flag", false, true, config.Config{}, llm.DefaultBaseURL, true}, {"off with -no-claude-code-auth", true, false, on, llm.DefaultBaseURL, false}, {"never to a gateway, config on", false, false, on, "http://gateway.example", false}, {"never to a gateway, flag on", false, true, config.Config{}, "http://gateway.example", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { home := tripwireHome(t) if tc.borrow { home = fakeHome(t) } src, note, err := resolveClaudeCode(noCredentials(tc.base), home, claudeCodeAllowed(tc.refuse, tc.request, tc.cfg)) if err != nil { t.Fatalf("resolveClaudeCode read the credentials file (err = %v); it must not touch disk unless the login was asked for and may be sent", err) } if got := src != nil; got != tc.borrow { t.Fatalf("borrowed = %v, want %v", got, tc.borrow) } if !tc.borrow { return } tok, err := src.Token(context.Background()) if err != nil { t.Fatalf("token: %v", err) } if tok != "file-token" { t.Errorf("token = %q, want file-token", tok) } if note == "" { t.Error("using a borrowed credential should be announced") } }) } } // An explicit credential wins, even over a login that was asked for. We never // override what the user typed. func TestClaudeCodeIsSkippedWhenACredentialExists(t *testing.T) { c := llm.New(llm.WithBaseURL(llm.DefaultBaseURL), llm.WithAPIKey("sk-ant-explicit")) src, _, err := resolveClaudeCode(c, tripwireHome(t), true) if err != nil { t.Fatalf("resolveClaudeCode read the file despite an explicit credential: %v", err) } if src != nil { t.Error("an explicit credential must not be replaced by the file") } } // Asked for and not logged in is not an error. It falls through to the // no-credentials message. func TestClaudeCodeMissingFileIsNotAnError(t *testing.T) { src, _, err := resolveClaudeCode(noCredentials(llm.DefaultBaseURL), t.TempDir(), true) if err != nil { t.Fatalf("a missing file is not an error, got %v", err) } if src != nil { t.Error("no file means no source") } } // A broken file the user asked us to use is worth stopping for, with the // path, rather than a "no credentials" that hides why. func TestClaudeCodeBrokenFileIsAnErrorOnceAskedFor(t *testing.T) { _, _, err := resolveClaudeCode(noCredentials(llm.DefaultBaseURL), tripwireHome(t), true) if err == nil || !strings.Contains(err.Error(), ".credentials.json") { t.Errorf("err = %v, want a parse error naming the file", err) } } // borrowClaudeCode is what run builds its client with. A borrowed login has // to end up on the client and in the options a second client is built from; // one that was not asked for leaves both alone. func TestBorrowClaudeCodeRebuildsTheClient(t *testing.T) { cleanEnv(t) opts := []llm.Option{llm.WithModel("codey-test-model")} b, err := borrowClaudeCode(opts, tripwireHome(t), false) if err != nil { t.Fatalf("not asked for: %v", err) } if b.client.HasCredentials() || b.source != nil || len(b.opts) != len(opts) { t.Fatalf("a login nobody asked for was borrowed: %+v", b) } b, err = borrowClaudeCode(opts, fakeHome(t), true) if err != nil { t.Fatal(err) } if !b.client.HasCredentials() || b.source == nil || b.note == "" { t.Fatalf("asked for, but not borrowed: %+v", b) } if len(b.opts) != len(opts)+1 { t.Errorf("the options a second client is built from do not carry the login: %d, want %d", len(b.opts), len(opts)+1) } if again := llm.New(b.opts...); !again.HasCredentials() || again.Model() != "codey-test-model" { t.Error("a client rebuilt from the options differs from the one in use") } } // The question only arises where its answer changes something. func TestClaudeCodeAskable(t *testing.T) { cases := []struct { name string client *llm.Client home string want bool }{ {"no key, Anthropic, logged in", noCredentials(llm.DefaultBaseURL), fakeHome(t), true}, {"an API key is set", llm.New(llm.WithBaseURL(llm.DefaultBaseURL), llm.WithAPIKey("k")), fakeHome(t), false}, {"a gateway", noCredentials("http://gateway.example"), fakeHome(t), false}, {"Claude Code not logged in", noCredentials(llm.DefaultBaseURL), t.TempDir(), false}, {"no home directory", noCredentials(llm.DefaultBaseURL), "", false}, } for _, tc := range cases { if got := claudeCodeAskable(tc.client, tc.home); got != tc.want { t.Errorf("%s: askable = %v, want %v", tc.name, got, tc.want) } } } // The message says how to get a key, and mentions the Claude Code login — // with the terms — only on a machine where it could be borrowed. func TestNoCredentialsSaysHowToGetOne(t *testing.T) { plain := noCredentialsError(false, "~/.config/codey/config.json").Error() for _, want := range []string{"ANTHROPIC_API_KEY", apiKeysURL, "-base-url", "ANTHROPIC_AUTH_TOKEN"} { if !strings.Contains(plain, want) { t.Errorf("the message does not mention %q:\n%s", want, plain) } } if strings.Contains(plain, "Claude Code") { t.Errorf("the message offers a Claude Code login there is none of:\n%s", plain) } cc := noCredentialsError(true, "~/.config/codey/config.json").Error() for _, want := range []string{"-claude-code-auth", `"claude_code_auth": "on"`, "~/.config/codey/config.json", "terms"} { if !strings.Contains(cc, want) { t.Errorf("the message does not mention %q:\n%s", want, cc) } } if n := strings.Count(cc, "\n") + 1; n > 6 { t.Errorf("the message is %d lines; keep it short:\n%s", n, cc) } } // Asked once, at a terminal, of someone the config has never recorded an // answer for — and nowhere else. func TestShouldAskClaudeCode(t *testing.T) { unset := config.Config{} cases := []struct { name string cfg config.Config cfgErr error askable bool refused bool tty bool want bool }{ {"upgrading, at a terminal", unset, nil, true, false, true, true}, {"-p or a pipe", unset, nil, true, false, false, false}, {"already answered on", config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOn}, nil, true, false, true, false}, {"already answered off", config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOff}, nil, true, false, true, false}, {"-no-claude-code-auth", unset, nil, true, true, true, false}, {"nothing to borrow", unset, nil, false, false, true, false}, {"an unreadable config", unset, errors.New("bad json"), true, false, true, false}, } for _, tc := range cases { if got := shouldAskClaudeCode(tc.cfg, tc.cfgErr, tc.askable, tc.refused, tc.tty); got != tc.want { t.Errorf("%s: ask = %v, want %v", tc.name, got, tc.want) } } } // The answer is saved either way, so it is asked once; enter is no; and the // terms are on screen before the question is. func TestAskClaudeCodeSavesTheAnswer(t *testing.T) { cases := []struct { in string yes bool save string }{ {"y\n", true, config.ClaudeCodeAuthOn}, {"YES\n", true, config.ClaudeCodeAuthOn}, {"\n", false, config.ClaudeCodeAuthOff}, {"n\n", false, config.ClaudeCodeAuthOff}, {"", false, config.ClaudeCodeAuthOff}, // ctrl-d } for _, tc := range cases { var out bytes.Buffer var saved *config.Config yes, cfg := askClaudeCode(strings.NewReader(tc.in), &out, config.Config{Model: "kept"}, func(c config.Config) error { saved = &c return nil }) if yes != tc.yes { t.Errorf("%q: yes = %v, want %v", tc.in, yes, tc.yes) } if saved == nil || saved.ClaudeCodeAuth != tc.save || cfg.ClaudeCodeAuth != tc.save { t.Errorf("%q: saved %+v, want claude_code_auth %q", tc.in, saved, tc.save) } if saved != nil && saved.Model != "kept" { t.Errorf("%q: the rest of the config was not kept: %+v", tc.in, saved) } if !strings.Contains(out.String(), "terms") { t.Errorf("the question does not carry the terms caveat:\n%s", out.String()) } } } // refusingTransport answers every request with a 500 and never touches the // network, so a client on Anthropic's default endpoint can be exercised. type refusingTransport struct{} func (refusingTransport) RoundTrip(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader(`{}`)), Header: http.Header{}, Request: req, }, nil } // `codey models` reads its client off the same path run does, so a Claude // Code login counts as logged in exactly when the config turned it on — and // not merely because the file is there. func TestModelsProbeFollowsTheOptIn(t *testing.T) { cleanEnv(t) home := fakeHome(t) probe := func(cfg config.Config) string { rt := refusingTransport{} opts := []llm.Option{ llm.WithModel("codey-test-model"), llm.WithHTTPClient(&http.Client{Transport: rt}), } askable := claudeCodeAskable(llm.New(opts...), home) b, err := borrowClaudeCode(opts, home, claudeCodeAllowed(false, false, cfg)) if err != nil { t.Fatal(err) } var out bytes.Buffer printModels(&out, b.client, askable) return out.String() } out := probe(config.Config{}) if loggedIn, _, _ := parseLikeT3(out); loggedIn { t.Errorf("a Claude Code login nobody turned on counted as logged in:\n%s", out) } if !t3LoggedOut.MatchString(out) { t.Errorf("the client would read this as unknown rather than logged out:\n%s", out) } if !strings.Contains(out, `"claude_code_auth": "on"`) { t.Errorf("the logged-out line does not say how to turn the login on:\n%s", out) } out = probe(config.Config{ClaudeCodeAuth: config.ClaudeCodeAuthOn}) loggedIn, models, def := parseLikeT3(out) if !loggedIn { t.Errorf("config on, and the client would read this as logged out:\n%s", out) } if len(models) != 1 || def != "codey-test-model" { t.Errorf("models = %v, default = %q, from:\n%s", models, def, out) } }