token_refresh_service_candidates_test.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "strings"
  6. "sync"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/imroc/req/v3"
  11. "github.com/stretchr/testify/require"
  12. )
  13. type tokenRefreshCandidateRepo struct {
  14. AccountRepository
  15. mu sync.Mutex
  16. accounts []Account
  17. updatedCredentialIDs []int64
  18. setErrorCalls int
  19. setTempUnschedCalls int
  20. clearTempCalls int
  21. lastTempUnschedReason string
  22. listActiveCalls int
  23. }
  24. func (r *tokenRefreshCandidateRepo) ListActive(context.Context) ([]Account, error) {
  25. r.mu.Lock()
  26. defer r.mu.Unlock()
  27. r.listActiveCalls++
  28. return r.accounts, nil
  29. }
  30. func (r *tokenRefreshCandidateRepo) ListOAuthRefreshCandidatePage(_ context.Context, options OAuthRefreshPageOptions) (*OAuthRefreshCandidatePage, error) {
  31. candidates := make([]Account, 0, len(r.accounts))
  32. now := time.Now()
  33. for _, account := range r.accounts {
  34. if account.ID <= options.AfterID {
  35. continue
  36. }
  37. refreshToken, _ := account.Credentials["refresh_token"].(string)
  38. inRetryCooldown := account.TempUnschedulableUntil != nil &&
  39. account.TempUnschedulableUntil.After(now) &&
  40. strings.HasPrefix(account.TempUnschedulableReason, "token refresh retry exhausted:")
  41. platformAllowed := false
  42. for _, platform := range options.Platforms {
  43. if account.Platform == platform {
  44. platformAllowed = true
  45. break
  46. }
  47. }
  48. if options.ActiveOnly && account.Status != StatusActive ||
  49. !account.Schedulable ||
  50. account.Type != AccountTypeOAuth ||
  51. !platformAllowed ||
  52. options.RequireRefreshToken && strings.TrimSpace(refreshToken) == "" ||
  53. options.ExcludeRetryCooldown && inRetryCooldown {
  54. continue
  55. }
  56. candidates = append(candidates, account)
  57. if len(candidates) == options.Limit {
  58. break
  59. }
  60. }
  61. page := &OAuthRefreshCandidatePage{Accounts: candidates, HasMore: len(candidates) == options.Limit}
  62. if len(candidates) > 0 {
  63. page.NextAfterID = candidates[len(candidates)-1].ID
  64. }
  65. return page, nil
  66. }
  67. func (r *tokenRefreshCandidateRepo) UpdateCredentials(_ context.Context, id int64, _ map[string]any) error {
  68. r.mu.Lock()
  69. defer r.mu.Unlock()
  70. r.updatedCredentialIDs = append(r.updatedCredentialIDs, id)
  71. return nil
  72. }
  73. func (r *tokenRefreshCandidateRepo) SetError(context.Context, int64, string) error {
  74. r.mu.Lock()
  75. defer r.mu.Unlock()
  76. r.setErrorCalls++
  77. return nil
  78. }
  79. func (r *tokenRefreshCandidateRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error {
  80. r.mu.Lock()
  81. defer r.mu.Unlock()
  82. r.setTempUnschedCalls++
  83. r.lastTempUnschedReason = reason
  84. return nil
  85. }
  86. func (r *tokenRefreshCandidateRepo) ClearTempUnschedulable(context.Context, int64) error {
  87. r.mu.Lock()
  88. defer r.mu.Unlock()
  89. r.clearTempCalls++
  90. return nil
  91. }
  92. type tokenRefreshTestRefresher struct {
  93. err error
  94. }
  95. func (r *tokenRefreshTestRefresher) CanRefresh(*Account) bool { return true }
  96. func (r *tokenRefreshTestRefresher) NeedsRefresh(*Account, time.Duration) bool { return true }
  97. func (r *tokenRefreshTestRefresher) Refresh(context.Context, *Account) (map[string]any, error) {
  98. if r.err != nil {
  99. return nil, r.err
  100. }
  101. return map[string]any{"access_token": "new-access-token", "refresh_token": "new-refresh-token"}, nil
  102. }
  103. func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing.T) {
  104. future := time.Now().Add(10 * time.Minute)
  105. repo := &tokenRefreshCandidateRepo{
  106. accounts: []Account{
  107. {
  108. ID: 1,
  109. Platform: PlatformOpenAI,
  110. Type: AccountTypeOAuth,
  111. Status: StatusActive,
  112. Schedulable: true,
  113. Credentials: map[string]any{"refresh_token": "refresh-token"},
  114. },
  115. {
  116. ID: 2,
  117. Platform: PlatformOpenAI,
  118. Type: AccountTypeOAuth,
  119. Status: StatusActive,
  120. Schedulable: true,
  121. Credentials: map[string]any{},
  122. },
  123. {
  124. ID: 3,
  125. Platform: PlatformGemini,
  126. Type: AccountTypeAPIKey,
  127. Status: StatusActive,
  128. Credentials: map[string]any{"refresh_token": "refresh-token"},
  129. },
  130. {
  131. ID: 4,
  132. Platform: PlatformAntigravity,
  133. Type: AccountTypeOAuth,
  134. Status: StatusActive,
  135. Schedulable: true,
  136. Credentials: map[string]any{"refresh_token": "refresh-token"},
  137. TempUnschedulableUntil: &future,
  138. TempUnschedulableReason: "token refresh retry exhausted: network timeout",
  139. },
  140. {
  141. ID: 5,
  142. Platform: "other",
  143. Type: AccountTypeOAuth,
  144. Status: StatusActive,
  145. Schedulable: true,
  146. Credentials: map[string]any{"refresh_token": "refresh-token"},
  147. },
  148. {
  149. ID: 6,
  150. Platform: PlatformAntigravity,
  151. Type: AccountTypeOAuth,
  152. Status: StatusActive,
  153. Schedulable: true,
  154. Credentials: map[string]any{"refresh_token": "refresh-token"},
  155. Extra: map[string]any{"privacy_mode": AntigravityPrivacySet},
  156. TempUnschedulableUntil: &future,
  157. TempUnschedulableReason: "OAuth 401: unauthorized",
  158. },
  159. {
  160. ID: 7,
  161. Platform: PlatformOpenAI,
  162. Type: AccountTypeOAuth,
  163. Status: StatusActive,
  164. Schedulable: false,
  165. Credentials: map[string]any{"refresh_token": "permanently-rejected-token"},
  166. },
  167. },
  168. }
  169. svc := &TokenRefreshService{
  170. accountRepo: repo,
  171. candidatePager: repo,
  172. registrations: []tokenRefreshRegistration{
  173. {platform: PlatformOpenAI, refresher: &tokenRefreshTestRefresher{}},
  174. {platform: PlatformGemini, refresher: &tokenRefreshTestRefresher{}},
  175. {platform: PlatformAntigravity, refresher: &tokenRefreshTestRefresher{}},
  176. },
  177. refreshPolicy: DefaultBackgroundRefreshPolicy(),
  178. cfg: &config.TokenRefreshConfig{RefreshBeforeExpiryHours: 1, MaxRetries: 1},
  179. }
  180. svc.processRefresh()
  181. require.Zero(t, repo.listActiveCalls, "TokenRefreshService should not use the broad active-account query")
  182. require.ElementsMatch(t, []int64{1, 6}, repo.updatedCredentialIDs)
  183. require.Equal(t, 1, repo.clearTempCalls, "successful refresh should clear the OAuth 401 temp-unschedulable state")
  184. }
  185. func TestTokenRefreshService_RefreshFailureDoesNotCallPrivacy(t *testing.T) {
  186. tests := []struct {
  187. name string
  188. err error
  189. }{
  190. {name: "retry exhausted", err: errors.New("temporary upstream timeout")},
  191. {name: "non retryable", err: errors.New("invalid_grant: token revoked")},
  192. }
  193. for _, tt := range tests {
  194. t.Run(tt.name, func(t *testing.T) {
  195. repo := &tokenRefreshCandidateRepo{}
  196. svc := &TokenRefreshService{
  197. accountRepo: repo,
  198. refreshPolicy: DefaultBackgroundRefreshPolicy(),
  199. cfg: &config.TokenRefreshConfig{MaxRetries: 1, RetryBackoffSeconds: 0},
  200. privacyClientFactory: func(string) (*req.Client, error) {
  201. t.Fatalf("privacy client factory must not be called on refresh failure")
  202. return nil, errors.New("unexpected privacy call")
  203. },
  204. }
  205. account := &Account{
  206. ID: 11,
  207. Platform: PlatformOpenAI,
  208. Type: AccountTypeOAuth,
  209. Credentials: map[string]any{
  210. "access_token": "old-access-token",
  211. "refresh_token": "refresh-token",
  212. },
  213. }
  214. err := svc.refreshWithRetry(context.Background(), account, &tokenRefreshTestRefresher{err: tt.err}, nil, time.Hour)
  215. require.Error(t, err)
  216. if isNonRetryableRefreshError(tt.err) {
  217. require.Equal(t, 1, repo.setErrorCalls)
  218. require.Zero(t, repo.setTempUnschedCalls)
  219. } else {
  220. require.Zero(t, repo.setErrorCalls)
  221. require.Equal(t, 1, repo.setTempUnschedCalls)
  222. require.True(t, strings.HasPrefix(repo.lastTempUnschedReason, "token refresh retry exhausted:"))
  223. }
  224. })
  225. }
  226. }