Vectrum
Sign In
174 lines · 5.4 KB · Go
package claudecode

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// write builds a fake home directory holding one credentials file.
func write(t *testing.T, body string) 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(body), 0o600); err != nil {
		t.Fatal(err)
	}
	return home
}

const sample = `{"claudeAiOauth":{
  "accessToken":"sk-ant-oat01-access",
  "refreshToken":"sk-ant-ort01-refresh",
  "expiresAt":1789668269460,
  "refreshTokenExpiresAt":1791404074460,
  "scopes":["user:inference","user:profile"],
  "subscriptionType":"team"}}`

func TestLoadReadsEveryField(t *testing.T) {
	c, err := Load(write(t, sample))
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if c.AccessToken != "sk-ant-oat01-access" {
		t.Errorf("AccessToken = %q", c.AccessToken)
	}
	if c.RefreshToken != "sk-ant-ort01-refresh" {
		t.Errorf("RefreshToken = %q", c.RefreshToken)
	}
	if c.SubscriptionType != "team" {
		t.Errorf("SubscriptionType = %q", c.SubscriptionType)
	}
	if len(c.Scopes) != 2 || c.Scopes[0] != "user:inference" {
		t.Errorf("Scopes = %v", c.Scopes)
	}
}

// The file stores epoch milliseconds. Reading them as seconds puts expiry
// 55,000 years out and the token never looks stale.
func TestLoadTreatsTimestampsAsMilliseconds(t *testing.T) {
	c, err := Load(write(t, sample))
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	want := time.UnixMilli(1789668269460)
	if !c.ExpiresAt.Equal(want) {
		t.Errorf("ExpiresAt = %s, want %s", c.ExpiresAt, want)
	}
}

// Not logged in is not a failure. It is a fall-through to the next source.
func TestLoadMissingFileIsErrNotFound(t *testing.T) {
	_, err := Load(t.TempDir())
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("err = %v, want ErrNotFound", err)
	}
}

// Present only looks. A file it cannot parse is still a file, because the
// question it answers is whether there is a login to ask about, and the
// answer to a broken one is Load's error once someone has said yes.
func TestPresentOnlyChecksTheFileIsThere(t *testing.T) {
	if Present(t.TempDir()) {
		t.Error("an empty home has no Claude Code login")
	}
	if Present("") {
		t.Error("no home directory has no Claude Code login")
	}
	if !Present(write(t, "{not json")) {
		t.Error("a credentials file is present whatever is in it")
	}
}

// A file that exists but does not parse is a broken install, and saying so
// with the path beats falling through to "no credentials".
func TestLoadMalformedJSONNamesThePath(t *testing.T) {
	home := write(t, "{not json")
	_, err := Load(home)
	if err == nil || errors.Is(err, ErrNotFound) {
		t.Fatalf("err = %v, want a parse error", err)
	}
	if !strings.Contains(err.Error(), ".credentials.json") {
		t.Errorf("error should name the file, got %q", err)
	}
}

// An empty access token is a file we cannot use, and it fails at parse time
// rather than as a puzzling 401 later.
func TestLoadEmptyAccessTokenIsAnError(t *testing.T) {
	_, err := Load(write(t, `{"claudeAiOauth":{"accessToken":""}}`))
	if err == nil || errors.Is(err, ErrNotFound) {
		t.Fatalf("err = %v, want an error about the missing token", err)
	}
}

// A missing expiresAt parses to zero, which time.UnixMilli turns into 1970 —
// indistinguishable from an actually-expired token unless Load rejects it up
// front. Silently accepting it would surface as the misleading "your Claude
// Code login has expired" rather than the schema-drift error this package
// promises.
func TestLoadMissingExpiresAtIsAnError(t *testing.T) {
	_, err := Load(write(t, `{"claudeAiOauth":{"accessToken":"tok"}}`))
	if err == nil || errors.Is(err, ErrNotFound) {
		t.Fatalf("err = %v, want an error about the missing expiresAt", err)
	}
	if errors.Is(err, ErrExpired) {
		t.Fatal("a missing expiresAt is a parse error, not an expired token")
	}
}

func TestExpiredUsesASkewMargin(t *testing.T) {
	base := time.Unix(1_700_000_000, 0)
	c := &Credentials{ExpiresAt: base}
	if c.Expired(base.Add(-5 * time.Minute)) {
		t.Error("a token with five minutes left is not expired")
	}
	// Inside the skew margin: it would expire during the request we are about
	// to send, so it counts as expired now.
	if !c.Expired(base.Add(-30 * time.Second)) {
		t.Error("a token expiring in 30s should count as expired")
	}
	if !c.Expired(base.Add(time.Minute)) {
		t.Error("a token past its expiry is expired")
	}
}

// A token that has not lapsed is handed straight back, with no refresh and no
// network of any kind.
func TestSourceReturnsTheAccessToken(t *testing.T) {
	s := NewSource(&Credentials{
		AccessToken: "tok",
		ExpiresAt:   time.Now().Add(time.Hour),
	}, nil, "")
	got, err := s.Token(context.Background())
	if err != nil {
		t.Fatalf("Token: %v", err)
	}
	if got != "tok" {
		t.Errorf("Token = %q, want tok", got)
	}
}

// An expired token with nothing to refresh it by is a clear instruction rather
// than a puzzling rejection from the API. Refreshing when there IS a usable
// refresh token is covered in refresh_test.go.
func TestSourceExpiredTokenSaysWhatToDo(t *testing.T) {
	s := NewSource(&Credentials{
		AccessToken: "tok",
		ExpiresAt:   time.Now().Add(-time.Hour),
	}, nil, "")
	_, err := s.Token(context.Background())
	if !errors.Is(err, ErrExpired) {
		t.Fatalf("err = %v, want ErrExpired", err)
	}
	if !strings.Contains(err.Error(), "claude") {
		t.Errorf("the error should say to run claude, got %q", err)
	}
}