Vectrum
Sign In
381 lines · 13.6 KB · Go
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)
	}
}