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