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