diff --git a/internal/auth/oauth_test.go b/internal/auth/oauth_test.go index 4ab0705..004f23e 100644 --- a/internal/auth/oauth_test.go +++ b/internal/auth/oauth_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "sync/atomic" "testing" + "time" "github.com/codag-megalith/codag-cli/internal/api" "github.com/codag-megalith/codag-cli/internal/auth" @@ -200,6 +201,100 @@ func TestRequestDeviceCodeIncludesAnonymousToken(t *testing.T) { } } +// TestEnsureFreshToken_30sExpiryBuffer verifies that EnsureFreshToken +// returns the cached token when ExpiresAt is outside a 30s proactive +// refresh window, and triggers a refresh when inside it or expired. +// If the buffer constant in refresh.go is changed, these test values +// must be updated to match. +func TestEnsureFreshToken_30sExpiryBuffer(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + var refreshCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth/token" || r.FormValue("grant_type") != "refresh_token" { + return + } + refreshCalls.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "AT-fresh", + "refresh_token": "RT-2", + "token_type": "Bearer", + "expires_in": 3600, + }) + })) + defer srv.Close() + + auth.SetEndpoints(auth.Endpoints{Server: srv.URL, HTTP: srv.Client()}) + + cfg := &config.Config{ + AccessToken: "AT-old", + RefreshToken: "RT-1", + } + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + + // Case 1: no expiry set → cached path + cfg.ExpiresAt = 0 + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + tok, err := auth.EnsureFreshToken() + if err != nil { + t.Fatalf("case 1 (no expiry): %v", err) + } + if tok != "AT-old" { + t.Fatalf("case 1 (no expiry): got %q, want AT-old", tok) + } + prev := refreshCalls.Load() + + // Case 2: 45s remaining → outside 30s buffer → cached path + cfg.ExpiresAt = time.Now().Unix() + 45 + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + tok, err = auth.EnsureFreshToken() + if err != nil { + t.Fatalf("case 2 (outside buffer, 45s): %v", err) + } + if tok != "AT-old" { + t.Fatalf("case 2 (outside buffer, 45s): got %q, want AT-old", tok) + } + if calls := refreshCalls.Load(); calls != prev { + t.Fatalf("case 2 (outside buffer, 45s): unexpected refresh (%d calls)", calls-prev) + } + prev = refreshCalls.Load() + + // Case 3: 15s remaining → inside 30s buffer → refresh path + cfg.ExpiresAt = time.Now().Unix() + 15 + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + tok, err = auth.EnsureFreshToken() + if err != nil { + t.Fatalf("case 3 (inside buffer, 15s): %v", err) + } + if tok == "AT-old" { + t.Fatalf("case 3 (inside buffer, 15s): got cached token %q, expected refresh path to return a new token", tok) + } + + // Case 4: already expired (10s ago) → refresh path + cfg.ExpiresAt = time.Now().Unix() - 10 + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + tok, err = auth.EnsureFreshToken() + if err != nil { + t.Fatalf("case 4 (expired, -10s): %v", err) + } + if tok == "AT-old" { + t.Fatalf("case 4 (expired, -10s): got cached token %q, expected refresh path to return a new token", tok) + } + +} + + // validBearer returns true if the request's Authorization header is // `Bearer `. func validBearer(r *http.Request, allowed ...string) bool {