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)
}
if want := time.UnixMilli(1791404074460); !c.RefreshExpiresAt.Equal(want) {
t.Errorf("RefreshExpiresAt = %s, want %s", c.RefreshExpiresAt, 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)
}
}
// 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)
}
}
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")
}
}
func TestRefreshExpiredIsTerminal(t *testing.T) {
base := time.Unix(1_700_000_000, 0)
c := &Credentials{RefreshExpiresAt: base}
if c.RefreshExpired(base.Add(-24 * time.Hour)) {
t.Error("a refresh token with a day left is not expired")
}
if !c.RefreshExpired(base.Add(time.Hour)) {
t.Error("a refresh token past its expiry is expired")
}
}
func TestSourceReturnsTheAccessToken(t *testing.T) {
s := NewSource(&Credentials{
AccessToken: "tok",
ExpiresAt: time.Now().Add(time.Hour),
})
got, err := s.Token(context.Background())
if err != nil {
t.Fatalf("Token: %v", err)
}
if got != "tok" {
t.Errorf("Token = %q, want tok", got)
}
}
// Until refresh lands, an expired token is a clear instruction rather than a
// 401 from the API.
func TestSourceExpiredTokenSaysWhatToDo(t *testing.T) {
s := NewSource(&Credentials{
AccessToken: "tok",
ExpiresAt: time.Now().Add(-time.Hour),
})
_, 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)
}
}