Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions internal/auth/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <one of the listed tokens>`.
func validBearer(r *http.Request, allowed ...string) bool {
Expand Down