package claudecode import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" ) // liveCreds is a credential that has lapsed but whose refresh token has not, // which is the only state in which a refresh should be attempted. func liveCreds() *Credentials { return &Credentials{ AccessToken: "stale", RefreshToken: "r1", ExpiresAt: time.Now().Add(-time.Hour), } } func TestRefreshExchangesTheToken(t *testing.T) { var body map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewDecoder(r.Body).Decode(&body) io.WriteString(w, `{"access_token":"new-access","refresh_token":"new-refresh","expires_in":28800}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) got, err := Refresh(context.Background(), srv.Client(), &Credentials{AccessToken: "old", RefreshToken: "old-refresh"}) if err != nil { t.Fatalf("Refresh: %v", err) } if got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" { t.Errorf("got %q/%q, want new-access/new-refresh", got.AccessToken, got.RefreshToken) } if body["grant_type"] != "refresh_token" || body["refresh_token"] != "old-refresh" { t.Errorf("request body = %v", body) } if body["client_id"] != ClientID { t.Errorf("client_id = %v, want %s", body["client_id"], ClientID) } // expires_in is a duration; the stored form is an absolute instant. if !got.ExpiresAt.After(time.Now().Add(7 * time.Hour)) { t.Errorf("ExpiresAt = %s, want ~8h out", got.ExpiresAt) } } // A response that omits refresh_token means the old one stays valid. Blanking // it would throw away the only thing that can refresh again. func TestRefreshKeepsTheOldRefreshTokenWhenNoneIsReturned(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `{"access_token":"new-access","expires_in":28800}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) got, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "keep-me"}) if err != nil { t.Fatalf("Refresh: %v", err) } if got.RefreshToken != "keep-me" { t.Errorf("RefreshToken = %q, want the original kept", got.RefreshToken) } } func TestRefreshRejectedSaysToLogInAgain(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) io.WriteString(w, `{"error":"invalid_grant"}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "dead"}) if err == nil || !strings.Contains(err.Error(), "claude") { t.Fatalf("err = %v, want an error telling the user to run claude", err) } } // A 429 from these OAuth surfaces is not reliable evidence of a rate limit — // the Messages API answers a request shape it declines the same way — so the // advice points at `claude` rather than at waiting. But it is equally not // evidence of a refusal, so it must not wrap ErrExpired: that is what stops // the caller latching it and disabling refresh for the session. func TestRefreshRateLimitedDoesNotAdviseWaiting(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTooManyRequests) io.WriteString(w, `{"error":{"type":"rate_limit_error","message":"Rate limited. Please try again later."}}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "r"}) if err == nil { t.Fatal("a 429 must be an error") } if !strings.Contains(err.Error(), "claude") { t.Errorf("a 429 should still point at `claude`, got %q", err) } if errors.Is(err, ErrExpired) { t.Errorf("a 429 must not be classified as a refusal, or the source latches it: %v", err) } } func TestRefreshTransportErrorIsReported(t *testing.T) { t.Setenv(tokenURLEnv, "http://127.0.0.1:1") _, err := Refresh(context.Background(), &http.Client{Timeout: time.Second}, &Credentials{RefreshToken: "r"}) if err == nil { t.Fatal("a transport error must be reported") } } // The point of the feature: an expired token becomes a working one without the // caller knowing anything happened, and only one exchange is spent doing it. func TestSourceRefreshesOnExpiryAndCaches(t *testing.T) { var hits int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ io.WriteString(w, `{"access_token":"fresh","refresh_token":"r1","expires_in":28800}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) s := NewSource(liveCreds(), srv.Client(), "") for i := 0; i < 3; i++ { tok, err := s.Token(context.Background()) if err != nil { t.Fatalf("Token: %v", err) } if tok != "fresh" { t.Fatalf("Token = %q, want fresh", tok) } } if hits != 1 { t.Errorf("refreshed %d times, want 1 — the result should be cached", hits) } } func TestSourceConcurrentTokensRefreshOnce(t *testing.T) { var mu sync.Mutex var hits int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() hits++ mu.Unlock() io.WriteString(w, `{"access_token":"fresh","refresh_token":"r1","expires_in":28800}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) s := NewSource(liveCreds(), srv.Client(), "") var wg sync.WaitGroup for i := 0; i < 8; i++ { wg.Add(1) go func() { defer wg.Done() if _, err := s.Token(context.Background()); err != nil { t.Error(err) } }() } wg.Wait() if hits != 1 { t.Errorf("refreshed %d times under concurrency, want 1", hits) } } // A refusal is terminal, and asking again on every request would spend a round // trip to be told the same thing. The answer is remembered instead. func TestSourceRemembersARefusal(t *testing.T) { var hits int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ w.WriteHeader(http.StatusBadRequest) io.WriteString(w, `{"error":"invalid_grant"}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) s := NewSource(liveCreds(), srv.Client(), "") for i := 0; i < 4; i++ { if _, err := s.Token(context.Background()); !errors.Is(err, ErrExpired) { t.Fatalf("call %d: err = %v, want ErrExpired", i, err) } } if hits != 1 { t.Errorf("asked the server %d times, want 1 — a refusal should be remembered", hits) } } func TestSourceMissingRefreshTokenDoesNotCallOut(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("no request should be sent without a refresh token") })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) c := liveCreds() c.RefreshToken = "" s := NewSource(c, srv.Client(), "") if _, err := s.Token(context.Background()); !errors.Is(err, ErrExpired) { t.Fatalf("err = %v, want ErrExpired", err) } } // storeWith writes a credentials file carrying a key this package does not // model, so a round trip through Save can be checked for dropping it. func storeWith(t *testing.T, refresh string) string { t.Helper() home := t.TempDir() if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o700); err != nil { t.Fatal(err) } body := `{"claudeAiOauth":{"accessToken":"stale","refreshToken":"` + refresh + `", "expiresAt":1789668269460,"refreshTokenExpiresAt":1791404074460, "scopes":["user:inference"],"subscriptionType":"team", "rateLimitTier":"default_claude_ai"},"someOtherTool":{"keep":"me"}}` if err := os.WriteFile(filepath.Join(home, Filename), []byte(body), 0o600); err != nil { t.Fatal(err) } return home } func readStore(t *testing.T, home string) map[string]any { t.Helper() raw, err := os.ReadFile(filepath.Join(home, Filename)) if err != nil { t.Fatal(err) } var doc map[string]any if err := json.Unmarshal(raw, &doc); err != nil { t.Fatalf("file is no longer valid JSON after Save: %v", err) } return doc } // Save rewrites a file another program owns, so it must put back everything it // did not come to change — including keys this package has never heard of. func TestSavePreservesUnknownKeys(t *testing.T) { home := storeWith(t, "r1") c, err := Load(home) if err != nil { t.Fatal(err) } c.AccessToken, c.RefreshToken = "new-access", "new-refresh" c.ExpiresAt = time.UnixMilli(1999999999000) if err := Save(home, c, ""); err != nil { t.Fatalf("Save: %v", err) } doc := readStore(t, home) oauth, ok := doc["claudeAiOauth"].(map[string]any) if !ok { t.Fatal("claudeAiOauth is missing after Save") } if oauth["rateLimitTier"] != "default_claude_ai" { t.Errorf("rateLimitTier = %v, want it preserved — Save dropped a key it does not model", oauth["rateLimitTier"]) } if oauth["subscriptionType"] != "team" { t.Errorf("subscriptionType = %v, want it preserved", oauth["subscriptionType"]) } if _, ok := doc["someOtherTool"]; !ok { t.Error("Save dropped a whole top-level key it does not model") } if oauth["accessToken"] != "new-access" || oauth["refreshToken"] != "new-refresh" { t.Errorf("Save did not write the new tokens: %v", oauth) } // The stored form is epoch milliseconds, and it must stay an integer // literal rather than turning into 1.999999999e+12. if got := string(mustRaw(t, home)); !strings.Contains(got, "1999999999000") { t.Errorf("expiresAt was not written as an integer millisecond literal:\n%s", got) } } func mustRaw(t *testing.T, home string) []byte { t.Helper() raw, err := os.ReadFile(filepath.Join(home, Filename)) if err != nil { t.Fatal(err) } return raw } func TestSaveKeepsTheFileOwnerOnly(t *testing.T) { home := storeWith(t, "r1") c, err := Load(home) if err != nil { t.Fatal(err) } // Loosen it first, or this test cannot tell "Save sets 0600" from // "Save did nothing at all". if err := os.Chmod(filepath.Join(home, Filename), 0o644); err != nil { t.Fatal(err) } if err := Save(home, c, ""); err != nil { t.Fatalf("Save: %v", err) } info, err := os.Stat(filepath.Join(home, Filename)) if err != nil { t.Fatal(err) } if perm := info.Mode().Perm(); perm != 0o600 { t.Errorf("mode = %o, want 600 — a credential must not become readable", perm) } } // The whole rotation policy, in two tests. When the server hands back the same // refresh token, the file is Claude Code's business and we do not touch it. func TestSourceDoesNotWriteBackWhenNotRotated(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `{"access_token":"fresh","refresh_token":"r1","expires_in":28800}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) home := storeWith(t, "r1") before := mustRaw(t, home) c := liveCreds() c.RefreshToken = "r1" s := NewSource(c, srv.Client(), home) if _, err := s.Token(context.Background()); err != nil { t.Fatalf("Token: %v", err) } if got := mustRaw(t, home); string(got) != string(before) { t.Errorf("the file was rewritten despite no rotation:\nbefore %s\nafter %s", before, got) } } // When the server rotates, the stored refresh token is already dead. Leaving // it there is what breaks the user's Claude Code login, so this is the one // case where writing is the safe act rather than the risky one. func TestSourceWritesBackWhenRotated(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2-rotated","expires_in":28800}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) home := storeWith(t, "r1") c := liveCreds() c.RefreshToken = "r1" s := NewSource(c, srv.Client(), home) if _, err := s.Token(context.Background()); err != nil { t.Fatalf("Token: %v", err) } oauth := readStore(t, home)["claudeAiOauth"].(map[string]any) if oauth["refreshToken"] != "r2-rotated" { t.Errorf("refreshToken = %v, want the rotated one written back", oauth["refreshToken"]) } if oauth["accessToken"] != "fresh" { t.Errorf("accessToken = %v, want it written back alongside", oauth["accessToken"]) } if oauth["rateLimitTier"] != "default_claude_ai" { t.Error("write-back dropped an unmodelled key") } } // Write-back is best-effort: a read-only store is a worse session, not a // failed one, and failing the request would be a strictly worse outcome than // running on the token we just successfully obtained. func TestSourceSurvivesAFailedWriteBack(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2-rotated","expires_in":28800}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) c := liveCreds() c.RefreshToken = "r1" // A home with no credentials file at all: Save cannot succeed. s := NewSource(c, srv.Client(), t.TempDir()) tok, err := s.Token(context.Background()) if err != nil { t.Fatalf("a failed write-back must not fail the request: %v", err) } if tok != "fresh" { t.Errorf("Token = %q, want the refreshed token", tok) } } // With no home, write-back is disabled outright and refresh still works. func TestSourceWithoutAHomeStillRefreshes(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2","expires_in":28800}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) s := NewSource(liveCreds(), srv.Client(), "") tok, err := s.Token(context.Background()) if err != nil { t.Fatalf("Token: %v", err) } if tok != "fresh" { t.Errorf("Token = %q, want fresh", tok) } } // A 200 with no usable lifetime would mint a token that is already expired, // so the very next request refreshes again — an endless loop of our own making // against an endpoint we already know answers 429. func TestRefreshWithoutExpiresInIsAnError(t *testing.T) { var hits int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ io.WriteString(w, `{"access_token":"new-access","refresh_token":"r1"}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) if _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "r1"}); err == nil { t.Fatal("a response with no expires_in must be an error, not a token born expired") } // And the Source must not spin on it. s := NewSource(liveCreds(), srv.Client(), "") for i := 0; i < 3; i++ { if _, err := s.Token(context.Background()); err == nil { t.Fatal("Token should surface the malformed refresh") } } if hits > 4 { t.Errorf("sent %d requests — a malformed response should not become a retry loop", hits) } } // A captive portal or proxy interstitial arrives as a 200 full of HTML. func TestRefreshNonJSONResponseSaysWhatArrived(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `Attention Required! Cloudflare`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "r"}) if err == nil { t.Fatal("HTML is not a token response") } if !strings.Contains(err.Error(), "Cloudflare") { t.Errorf("the error should quote what actually arrived, got %q", err) } } // A refusal is a terminal expiry, so callers can classify it without reading // status codes back out of a string. func TestRefreshRefusalIsErrExpired(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) io.WriteString(w, `{"error":"invalid_grant"}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "dead"}) if !errors.Is(err, ErrExpired) { t.Fatalf("err = %v, want it to wrap ErrExpired", err) } } // An enormous error body must not become the error message, and must not carry // a credential back to the screen wholesale. func TestRefreshErrorBodyIsBounded(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) io.WriteString(w, strings.Repeat("A", 100_000)) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "r"}) if err == nil { t.Fatal("expected an error") } if len(err.Error()) > 2*maxErrorBody { t.Errorf("error is %d bytes; the response body was not bounded", len(err.Error())) } } // If the user runs `claude` and logs in afresh while we are running, their new // credential is strictly better than ours. Overwriting it would undo a login // they just performed. func TestSaveDeclinesWhenTheStoreMovedOn(t *testing.T) { home := storeWith(t, "somebody-elses-newer-token") c, err := Load(home) if err != nil { t.Fatal(err) } before := mustRaw(t, home) c.AccessToken = "ours" if err := Save(home, c, "the-one-we-refreshed-from"); !errors.Is(err, ErrStoreChanged) { t.Fatalf("err = %v, want ErrStoreChanged", err) } if got := mustRaw(t, home); string(got) != string(before) { t.Error("Save wrote despite the store having changed underneath it") } } // A write that cannot happen must leave the original readable, not truncated. func TestSaveFailureLeavesTheOriginalIntact(t *testing.T) { home := storeWith(t, "r1") c, err := Load(home) if err != nil { t.Fatal(err) } before := mustRaw(t, home) // A read-only directory: the temp file cannot be created. dir := filepath.Join(home, ".claude") if err := os.Chmod(dir, 0o500); err != nil { t.Skip("cannot make the directory read-only here") } t.Cleanup(func() { os.Chmod(dir, 0o700) }) c.AccessToken = "new" if err := Save(home, c, ""); err == nil { t.Skip("the directory was writable after all (running as root?)") } if got := mustRaw(t, home); string(got) != string(before) { t.Errorf("a failed Save damaged the original:\n%s", got) } } // The scenario the whole design exists to prevent: the server rotated, we // cannot store the replacement, and the token on disk is now dead. The request // still succeeds — but it must not do so silently. func TestRotatedButUnstorableIsReported(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2-rotated","expires_in":28800}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) c := liveCreds() s := NewSource(c, srv.Client(), t.TempDir()) // a home with no store in it var reported error s.OnWriteBackFailure = func(err error) { reported = err } tok, err := s.Token(context.Background()) if err != nil { t.Fatalf("the request must still succeed: %v", err) } if tok != "fresh" { t.Errorf("Token = %q, want fresh", tok) } if reported == nil { t.Fatal("a rotation that could not be stored must be reported, never swallowed") } } // No rotation, no write, and therefore nothing to report. func TestNoRotationReportsNothing(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `{"access_token":"fresh","refresh_token":"r1","expires_in":28800}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) s := NewSource(liveCreds(), srv.Client(), t.TempDir()) var called bool s.OnWriteBackFailure = func(error) { called = true } if _, err := s.Token(context.Background()); err != nil { t.Fatal(err) } if called { t.Error("nothing was rotated, so nothing should have been written or reported") } } // A real token response is far bigger than any fixture above: two ~120-char // opaque tokens plus whatever else the server includes. An earlier version // bounded the read at the error-clip size, so a realistic body was truncated, // every refresh failed to parse, and the truncated body — containing both // tokens — was printed into the error. Small fixtures hid it completely. func TestRefreshHandlesARealisticallyLargeResponse(t *testing.T) { access := "sk-ant-oat01-" + strings.Repeat("x", 108) refresh := "sk-ant-ort01-" + strings.Repeat("y", 108) body := `{"access_token":"` + access + `","refresh_token":"` + refresh + `","expires_in":28800,"token_type":"Bearer","scope":"user:inference user:profile",` + `"organization":{"uuid":"` + strings.Repeat("0", 36) + `","name":"Some Organisation Name"},` + `"account":{"uuid":"` + strings.Repeat("1", 36) + `","email_address":"someone@example.com"}}` // The bug was that the read was bounded at the error-clip size, so this // fixture is only a regression test while it exceeds that. if len(body) <= maxErrorBody { t.Fatalf("fixture is %d bytes, not more than the %d-byte clip", len(body), maxErrorBody) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, body) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) got, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "old"}) if err != nil { t.Fatalf("a full-size token response must parse: %v", err) } if got.AccessToken != access { t.Errorf("access token was truncated: got %d chars, want %d", len(got.AccessToken), len(access)) } if got.RefreshToken != refresh { t.Errorf("refresh token was truncated: got %d chars, want %d", len(got.RefreshToken), len(refresh)) } } // And when a large body IS an error, the token must not ride along into the // message wholesale. func TestRefreshErrorDoesNotDumpTheWholeBody(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) io.WriteString(w, `{"error":"invalid_grant","echo":"`+strings.Repeat("Z", 5000)+`"}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "r"}) if err == nil { t.Fatal("expected an error") } if strings.Count(err.Error(), "Z") > maxErrorBody { t.Errorf("error carried %d bytes of body; it should be clipped to %d", strings.Count(err.Error(), "Z"), maxErrorBody) } } // A 429 is the only response this endpoint has ever produced, and we still do // not know whether it means "slow down" or "no". Latching it would disable // refresh for the rest of the session on a login that is fine, so it must not // be remembered as a refusal. func TestTransientFailuresDoNotPoisonTheSource(t *testing.T) { for _, status := range []int{ http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable, } { t.Run(http.StatusText(status), func(t *testing.T) { var hits int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ if hits == 1 { w.WriteHeader(status) io.WriteString(w, `{"error":"transient"}`) return } io.WriteString(w, `{"access_token":"fresh","refresh_token":"r1","expires_in":28800}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) s := NewSource(liveCreds(), srv.Client(), "") if _, err := s.Token(context.Background()); err == nil { t.Fatal("the first call should surface the failure") } else if errors.Is(err, ErrExpired) { t.Errorf("a %d must not be reported as an expired login: %v", status, err) } // The server recovers; so must we. tok, err := s.Token(context.Background()) if err != nil { t.Fatalf("the source latched a transient failure: %v", err) } if tok != "fresh" { t.Errorf("Token = %q, want fresh", tok) } }) } } // A real refusal, by contrast, is remembered — and the reason survives with it // rather than collapsing into an unexplained "expired". func TestRefusalIsRememberedWithItsReason(t *testing.T) { for _, status := range []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden} { t.Run(http.StatusText(status), func(t *testing.T) { var hits int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ w.WriteHeader(status) io.WriteString(w, `{"error":"invalid_grant"}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) s := NewSource(liveCreds(), srv.Client(), "") for i := 0; i < 3; i++ { _, err := s.Token(context.Background()) if !errors.Is(err, ErrExpired) { t.Fatalf("call %d: err = %v, want ErrExpired", i, err) } if !strings.Contains(err.Error(), "invalid_grant") { t.Errorf("call %d lost the reason: %v", i, err) } } if hits != 1 { t.Errorf("asked the server %d times, want 1", hits) } }) } } // Someone else writing a newer credential is good news, not a broken login, so // it must not reach the warning hook. func TestStoreChangedIsNotReportedAsAFailure(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2-rotated","expires_in":28800}`) })) defer srv.Close() t.Setenv(tokenURLEnv, srv.URL) // The store holds a token that is not the one we refreshed from. home := storeWith(t, "somebody-elses-newer-token") s := NewSource(liveCreds(), srv.Client(), home) var reported error s.OnWriteBackFailure = func(err error) { reported = err } if _, err := s.Token(context.Background()); err != nil { t.Fatalf("Token: %v", err) } if reported != nil { t.Errorf("a newer credential from someone else was reported as a failure: %v", reported) } } // The design now hinges on refreshTokenExpiresAt surviving as a key we do not // model, so it gets a named guard rather than riding on another test. func TestSavePreservesTheRefreshExpiryKeyThroughRotation(t *testing.T) { home := storeWith(t, "r1") c, err := Load(home) if err != nil { t.Fatal(err) } c.RefreshToken = "r2-rotated" if err := Save(home, c, "r1"); err != nil { t.Fatalf("Save: %v", err) } oauth := readStore(t, home)["claudeAiOauth"].(map[string]any) if _, ok := oauth["refreshTokenExpiresAt"]; !ok { t.Error("refreshTokenExpiresAt vanished; a key we do not model is not ours to drop") } if oauth["refreshToken"] != "r2-rotated" { t.Errorf("refreshToken = %v, want the rotated one", oauth["refreshToken"]) } }