oauth_refresh_api_test.go 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "reflect"
  7. "sync"
  8. "testing"
  9. "time"
  10. "github.com/stretchr/testify/require"
  11. )
  12. // ---------- mock helpers ----------
  13. // refreshAPIAccountRepo implements AccountRepository for OAuthRefreshAPI tests.
  14. type refreshAPIAccountRepo struct {
  15. mockAccountRepoForGemini
  16. account *Account // returned by GetByID
  17. getByIDErr error
  18. getByIDCalls int
  19. getByIDErrAfterCall int
  20. getByIDErrAfterCallErr error
  21. updateErr error
  22. updateCalls int
  23. updateCredentialsCalls int
  24. successCASCalls int
  25. beforeSuccessCAS func(*refreshAPIAccountRepo)
  26. lastExpectedCredentials map[string]any
  27. lastExpectedProxyID *int64
  28. }
  29. func (r *refreshAPIAccountRepo) GetByID(_ context.Context, _ int64) (*Account, error) {
  30. r.getByIDCalls++
  31. if r.getByIDErrAfterCall > 0 && r.getByIDCalls >= r.getByIDErrAfterCall {
  32. return nil, r.getByIDErrAfterCallErr
  33. }
  34. if r.getByIDErr != nil {
  35. return nil, r.getByIDErr
  36. }
  37. return activeRefreshAPITestAccount(r.account), nil
  38. }
  39. func activeRefreshAPITestAccount(account *Account) *Account {
  40. if account == nil || account.Status != "" {
  41. return account
  42. }
  43. copy := *account
  44. copy.Status = StatusActive
  45. return &copy
  46. }
  47. func (r *refreshAPIAccountRepo) Update(_ context.Context, _ *Account) error {
  48. r.updateCalls++
  49. return r.updateErr
  50. }
  51. func (r *refreshAPIAccountRepo) UpdateCredentials(_ context.Context, id int64, credentials map[string]any) error {
  52. r.updateCalls++
  53. r.updateCredentialsCalls++
  54. if r.updateErr != nil {
  55. return r.updateErr
  56. }
  57. if r.account == nil || r.account.ID != id {
  58. r.account = &Account{ID: id}
  59. }
  60. r.account.Credentials = shallowCopyMap(credentials)
  61. return nil
  62. }
  63. func (r *refreshAPIAccountRepo) UpdateGrokOAuthCredentialsIfUnchanged(
  64. _ context.Context,
  65. id int64,
  66. expectedCredentials map[string]any,
  67. expectedProxyID *int64,
  68. credentials map[string]any,
  69. ) (bool, error) {
  70. r.successCASCalls++
  71. r.lastExpectedCredentials = shallowCopyMap(expectedCredentials)
  72. if expectedProxyID != nil {
  73. proxyID := *expectedProxyID
  74. r.lastExpectedProxyID = &proxyID
  75. } else {
  76. r.lastExpectedProxyID = nil
  77. }
  78. if r.beforeSuccessCAS != nil {
  79. r.beforeSuccessCAS(r)
  80. }
  81. if r.updateErr != nil {
  82. return false, r.updateErr
  83. }
  84. if r.account == nil || r.account.ID != id || r.account.Platform != PlatformGrok ||
  85. r.account.Type != AccountTypeOAuth ||
  86. !reflect.DeepEqual(r.account.Credentials, expectedCredentials) ||
  87. !reflect.DeepEqual(r.account.ProxyID, expectedProxyID) {
  88. return false, nil
  89. }
  90. r.updateCalls++
  91. r.updateCredentialsCalls++
  92. r.account.Credentials = shallowCopyMap(credentials)
  93. return true, nil
  94. }
  95. // refreshAPIExecutorStub implements OAuthRefreshExecutor for tests.
  96. type refreshAPIExecutorStub struct {
  97. needsRefresh bool
  98. cannotRefresh bool
  99. credentials map[string]any
  100. err error
  101. refreshCalls int
  102. canRefresh func(*Account) bool
  103. onRefresh func()
  104. delay time.Duration
  105. }
  106. func (e *refreshAPIExecutorStub) CanRefresh(account *Account) bool {
  107. if e.cannotRefresh {
  108. return false
  109. }
  110. if e.canRefresh != nil {
  111. return e.canRefresh(account)
  112. }
  113. return true
  114. }
  115. func (e *refreshAPIExecutorStub) NeedsRefresh(_ *Account, _ time.Duration) bool {
  116. return e.needsRefresh
  117. }
  118. func (e *refreshAPIExecutorStub) Refresh(_ context.Context, _ *Account) (map[string]any, error) {
  119. e.refreshCalls++
  120. if e.delay > 0 {
  121. time.Sleep(e.delay)
  122. }
  123. if e.onRefresh != nil {
  124. e.onRefresh()
  125. }
  126. if e.err != nil {
  127. return nil, e.err
  128. }
  129. return e.credentials, nil
  130. }
  131. func (e *refreshAPIExecutorStub) CacheKey(account *Account) string {
  132. return "test:api:" + account.Platform
  133. }
  134. // refreshAPICacheStub implements GeminiTokenCache for OAuthRefreshAPI tests.
  135. type refreshAPICacheStub struct {
  136. lockResult bool
  137. lockErr error
  138. releaseCalls int
  139. releaseCtxErr error
  140. deleteCalls int
  141. deleteKey string
  142. deleteCtxErr error
  143. }
  144. func (c *refreshAPICacheStub) GetAccessToken(context.Context, string) (string, error) {
  145. return "", nil
  146. }
  147. func (c *refreshAPICacheStub) SetAccessToken(context.Context, string, string, time.Duration) error {
  148. return nil
  149. }
  150. func (c *refreshAPICacheStub) DeleteAccessToken(ctx context.Context, key string) error {
  151. c.deleteCalls++
  152. c.deleteKey = key
  153. c.deleteCtxErr = ctx.Err()
  154. return nil
  155. }
  156. func (c *refreshAPICacheStub) AcquireRefreshLock(context.Context, string, time.Duration) (bool, error) {
  157. return c.lockResult, c.lockErr
  158. }
  159. func (c *refreshAPICacheStub) ReleaseRefreshLock(ctx context.Context, _ string) error {
  160. c.releaseCalls++
  161. c.releaseCtxErr = ctx.Err()
  162. return nil
  163. }
  164. // ========== RefreshIfNeeded tests ==========
  165. func TestRefreshIfNeeded_Success(t *testing.T) {
  166. account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth, Status: StatusActive}
  167. repo := &refreshAPIAccountRepo{account: account}
  168. cache := &refreshAPICacheStub{lockResult: true}
  169. executor := &refreshAPIExecutorStub{
  170. needsRefresh: true,
  171. credentials: map[string]any{"access_token": "new-token"},
  172. }
  173. api := NewOAuthRefreshAPI(repo, cache)
  174. result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
  175. require.NoError(t, err)
  176. require.True(t, result.Refreshed)
  177. require.NotNil(t, result.NewCredentials)
  178. require.Equal(t, "new-token", result.NewCredentials["access_token"])
  179. require.NotNil(t, result.NewCredentials["_token_version"]) // version stamp set
  180. require.Equal(t, 1, repo.updateCalls) // DB updated
  181. require.Equal(t, 1, repo.updateCredentialsCalls)
  182. require.Equal(t, 1, cache.releaseCalls) // lock released
  183. require.Equal(t, 1, executor.refreshCalls)
  184. }
  185. func TestRefreshIfNeeded_UpdateCredentialsPreservesRateLimitState(t *testing.T) {
  186. resetAt := time.Now().Add(45 * time.Minute)
  187. account := &Account{
  188. ID: 11,
  189. Platform: PlatformGemini,
  190. Type: AccountTypeOAuth,
  191. Status: StatusActive,
  192. RateLimitResetAt: &resetAt,
  193. }
  194. repo := &refreshAPIAccountRepo{account: account}
  195. cache := &refreshAPICacheStub{lockResult: true}
  196. executor := &refreshAPIExecutorStub{
  197. needsRefresh: true,
  198. credentials: map[string]any{"access_token": "safe-token"},
  199. }
  200. api := NewOAuthRefreshAPI(repo, cache)
  201. result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
  202. require.NoError(t, err)
  203. require.True(t, result.Refreshed)
  204. require.Equal(t, 1, repo.updateCredentialsCalls)
  205. require.NotNil(t, repo.account.RateLimitResetAt)
  206. require.WithinDuration(t, resetAt, *repo.account.RateLimitResetAt, time.Second)
  207. }
  208. func TestRefreshIfNeeded_LockHeld(t *testing.T) {
  209. account := &Account{ID: 2, Platform: PlatformAnthropic, Status: StatusActive}
  210. repo := &refreshAPIAccountRepo{account: account}
  211. cache := &refreshAPICacheStub{lockResult: false} // lock not acquired
  212. executor := &refreshAPIExecutorStub{needsRefresh: true}
  213. api := NewOAuthRefreshAPI(repo, cache)
  214. result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
  215. require.NoError(t, err)
  216. require.True(t, result.LockHeld)
  217. require.False(t, result.Refreshed)
  218. require.Equal(t, 0, repo.updateCalls)
  219. require.Equal(t, 0, executor.refreshCalls)
  220. }
  221. func TestRefreshIfNeeded_LockErrorDegrades(t *testing.T) {
  222. account := &Account{ID: 3, Platform: PlatformGemini, Type: AccountTypeOAuth, Status: StatusActive}
  223. repo := &refreshAPIAccountRepo{account: account}
  224. cache := &refreshAPICacheStub{lockErr: errors.New("redis down")} // lock error
  225. executor := &refreshAPIExecutorStub{
  226. needsRefresh: true,
  227. credentials: map[string]any{"access_token": "degraded-token"},
  228. }
  229. api := NewOAuthRefreshAPI(repo, cache)
  230. result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
  231. require.NoError(t, err)
  232. require.True(t, result.Refreshed) // still refreshed (degraded mode)
  233. require.Equal(t, 1, repo.updateCalls) // DB updated
  234. require.Equal(t, 0, cache.releaseCalls) // no lock to release
  235. require.Equal(t, 1, executor.refreshCalls)
  236. }
  237. func TestRefreshIfNeeded_NoCacheNoLock(t *testing.T) {
  238. account := &Account{ID: 4, Platform: PlatformGemini, Type: AccountTypeOAuth, Status: StatusActive}
  239. repo := &refreshAPIAccountRepo{account: account}
  240. executor := &refreshAPIExecutorStub{
  241. needsRefresh: true,
  242. credentials: map[string]any{"access_token": "no-cache-token"},
  243. }
  244. api := NewOAuthRefreshAPI(repo, nil) // no cache = no lock
  245. result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
  246. require.NoError(t, err)
  247. require.True(t, result.Refreshed)
  248. require.Equal(t, 1, repo.updateCalls)
  249. }
  250. func TestRefreshIfNeeded_AlreadyRefreshed(t *testing.T) {
  251. account := &Account{ID: 5, Platform: PlatformAnthropic, Status: StatusActive}
  252. repo := &refreshAPIAccountRepo{account: account}
  253. cache := &refreshAPICacheStub{lockResult: true}
  254. executor := &refreshAPIExecutorStub{needsRefresh: false} // already refreshed
  255. api := NewOAuthRefreshAPI(repo, cache)
  256. result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
  257. require.NoError(t, err)
  258. require.False(t, result.Refreshed)
  259. require.False(t, result.LockHeld)
  260. require.NotNil(t, result.Account) // returns fresh account
  261. require.Equal(t, 0, repo.updateCalls)
  262. require.Equal(t, 0, executor.refreshCalls)
  263. }
  264. func TestRefreshIfNeeded_RefreshError(t *testing.T) {
  265. account := &Account{ID: 6, Platform: PlatformAnthropic, Status: StatusActive}
  266. repo := &refreshAPIAccountRepo{account: account}
  267. cache := &refreshAPICacheStub{lockResult: true}
  268. executor := &refreshAPIExecutorStub{
  269. needsRefresh: true,
  270. err: errors.New("invalid_grant: token revoked"),
  271. }
  272. api := NewOAuthRefreshAPI(repo, cache)
  273. result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
  274. require.Error(t, err)
  275. require.NotNil(t, result)
  276. require.NotNil(t, result.Account)
  277. require.Equal(t, account.ID, result.Account.ID)
  278. require.Contains(t, err.Error(), "invalid_grant")
  279. require.Equal(t, 0, repo.updateCalls) // no DB update on refresh error
  280. require.Equal(t, 1, cache.releaseCalls) // lock still released via defer
  281. }
  282. func TestRefreshIfNeeded_DBUpdateError(t *testing.T) {
  283. account := &Account{ID: 7, Platform: PlatformGemini, Type: AccountTypeOAuth, Status: StatusActive}
  284. repo := &refreshAPIAccountRepo{
  285. account: account,
  286. updateErr: errors.New("db connection lost"),
  287. }
  288. cache := &refreshAPICacheStub{lockResult: true}
  289. executor := &refreshAPIExecutorStub{
  290. needsRefresh: true,
  291. credentials: map[string]any{"access_token": "token"},
  292. }
  293. api := NewOAuthRefreshAPI(repo, cache)
  294. result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
  295. require.Error(t, err)
  296. require.Nil(t, result)
  297. require.ErrorIs(t, err, errOAuthRefreshCredentialPersist)
  298. require.Equal(t, 1, repo.updateCalls) // attempted
  299. }
  300. func TestRefreshIfNeeded_GrokSuccessCASLetsConcurrentReauthorizationWin(t *testing.T) {
  301. proxyID := int64(17)
  302. account := &Account{
  303. ID: 70,
  304. Platform: PlatformGrok,
  305. Type: AccountTypeOAuth,
  306. Status: StatusActive,
  307. ProxyID: &proxyID,
  308. Credentials: map[string]any{
  309. "access_token": "attempted-access",
  310. "refresh_token": "attempted-refresh",
  311. "_token_version": int64(1),
  312. },
  313. }
  314. repo := &refreshAPIAccountRepo{account: account}
  315. repo.beforeSuccessCAS = func(r *refreshAPIAccountRepo) {
  316. repairedProxyID := int64(23)
  317. r.account.ProxyID = &repairedProxyID
  318. r.account.Credentials = map[string]any{
  319. "access_token": "reauthorized-access",
  320. "refresh_token": "reauthorized-refresh",
  321. "_token_version": int64(2),
  322. }
  323. }
  324. executor := &refreshAPIExecutorStub{
  325. needsRefresh: true,
  326. credentials: map[string]any{
  327. "access_token": "provider-access",
  328. "refresh_token": "provider-refresh",
  329. },
  330. }
  331. result, err := NewOAuthRefreshAPI(repo, nil).RefreshIfNeeded(context.Background(), account, executor, time.Hour)
  332. require.NoError(t, err)
  333. require.NotNil(t, result)
  334. require.False(t, result.Refreshed, "a lost success CAS is an already-refreshed skip")
  335. require.Nil(t, result.NewCredentials)
  336. require.Equal(t, "reauthorized-refresh", result.Account.GetGrokRefreshToken())
  337. require.NotNil(t, result.Account.ProxyID)
  338. require.Equal(t, int64(23), *result.Account.ProxyID)
  339. require.Equal(t, 1, repo.successCASCalls)
  340. require.Equal(t, "attempted-refresh", repo.lastExpectedCredentials["refresh_token"])
  341. require.NotNil(t, repo.lastExpectedProxyID)
  342. require.Equal(t, proxyID, *repo.lastExpectedProxyID)
  343. require.Zero(t, repo.updateCredentialsCalls, "the provider result must not overwrite a concurrent repair")
  344. }
  345. func TestRefreshIfNeeded_GrokSuccessPersistenceFailureIsProviderContainment(t *testing.T) {
  346. account := &Account{
  347. ID: 71,
  348. Platform: PlatformGrok,
  349. Type: AccountTypeOAuth,
  350. Status: StatusActive,
  351. Credentials: map[string]any{
  352. "access_token": "attempted-access",
  353. "refresh_token": "attempted-refresh",
  354. },
  355. }
  356. repo := &refreshAPIAccountRepo{account: account, updateErr: errors.New("database unavailable")}
  357. executor := &refreshAPIExecutorStub{
  358. needsRefresh: true,
  359. credentials: map[string]any{
  360. "access_token": "provider-access",
  361. "refresh_token": "provider-refresh",
  362. },
  363. }
  364. result, err := NewOAuthRefreshAPI(repo, nil).RefreshIfNeeded(context.Background(), account, executor, time.Hour)
  365. require.Error(t, err)
  366. require.Nil(t, result)
  367. var containmentErr *providerCycleContainmentRefreshError
  368. require.ErrorAs(t, err, &containmentErr)
  369. require.Equal(t, "attempted-refresh", account.GetGrokRefreshToken(),
  370. "an ambiguous persistence result must not mutate the in-memory account")
  371. require.Equal(t, 1, repo.successCASCalls)
  372. require.Zero(t, repo.updateCredentialsCalls)
  373. }
  374. func TestRefreshIfNeeded_GrokSuccessDurableRereadFailureIsProviderContainment(t *testing.T) {
  375. account := &Account{
  376. ID: 72,
  377. Platform: PlatformGrok,
  378. Type: AccountTypeOAuth,
  379. Status: StatusActive,
  380. Credentials: map[string]any{
  381. "access_token": "attempted-access",
  382. "refresh_token": "attempted-refresh",
  383. },
  384. }
  385. repo := &refreshAPIAccountRepo{
  386. account: account,
  387. getByIDErrAfterCall: 2,
  388. getByIDErrAfterCallErr: errors.New("durable state unavailable"),
  389. }
  390. cache := &refreshAPICacheStub{lockResult: true}
  391. executor := &refreshAPIExecutorStub{
  392. needsRefresh: true,
  393. credentials: map[string]any{
  394. "access_token": "provider-access",
  395. "refresh_token": "provider-refresh",
  396. },
  397. }
  398. result, err := NewOAuthRefreshAPI(repo, cache).RefreshIfNeeded(context.Background(), account, executor, time.Hour)
  399. require.Error(t, err)
  400. require.Nil(t, result)
  401. var containmentErr *providerCycleContainmentRefreshError
  402. require.ErrorAs(t, err, &containmentErr)
  403. require.Equal(t, 2, repo.getByIDCalls)
  404. require.Equal(t, 1, repo.successCASCalls)
  405. require.Equal(t, 1, cache.deleteCalls, "a committed credential rotation must invalidate the pre-rotation access-token cache")
  406. require.NoError(t, cache.deleteCtxErr)
  407. }
  408. func TestRefreshIfNeeded_DBRereadFails(t *testing.T) {
  409. account := &Account{ID: 8, Platform: PlatformAnthropic, Type: AccountTypeOAuth, Status: StatusActive}
  410. repo := &refreshAPIAccountRepo{
  411. account: nil, // GetByID returns nil
  412. getByIDErr: errors.New("db timeout"),
  413. }
  414. cache := &refreshAPICacheStub{lockResult: true}
  415. executor := &refreshAPIExecutorStub{
  416. needsRefresh: true,
  417. credentials: map[string]any{"access_token": "fallback-token"},
  418. }
  419. api := NewOAuthRefreshAPI(repo, cache)
  420. result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
  421. require.Error(t, err)
  422. var stateUnavailable *oauthRefreshStateUnavailableError
  423. require.ErrorAs(t, err, &stateUnavailable)
  424. require.Nil(t, result)
  425. require.Zero(t, executor.refreshCalls, "a failed DB reread must not refresh stale credentials")
  426. require.Zero(t, repo.updateCalls)
  427. require.Equal(t, 1, cache.releaseCalls)
  428. }
  429. func TestRefreshIfNeeded_RequestPathDBRereadNilFailsClosed(t *testing.T) {
  430. account := &Account{ID: 81, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true}
  431. repo := &refreshAPIAccountRepo{}
  432. cache := &refreshAPICacheStub{lockResult: true}
  433. executor := &refreshAPIExecutorStub{needsRefresh: true}
  434. api := NewOAuthRefreshAPI(repo, cache)
  435. result, err := api.RefreshIfNeeded(withOAuthRefreshRequestPath(context.Background()), account, executor, 3*time.Minute)
  436. require.ErrorIs(t, err, errOAuthRefreshAccountStateChanged)
  437. require.Nil(t, result)
  438. require.Zero(t, executor.refreshCalls)
  439. require.Zero(t, repo.updateCalls)
  440. require.Equal(t, 1, cache.releaseCalls)
  441. }
  442. func TestRefreshIfNeeded_RequestPathDBRereadInactiveFailsClosed(t *testing.T) {
  443. account := &Account{ID: 82, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true}
  444. freshAccount := &Account{ID: account.ID, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusDisabled}
  445. repo := &refreshAPIAccountRepo{account: freshAccount}
  446. executor := &refreshAPIExecutorStub{needsRefresh: true}
  447. api := NewOAuthRefreshAPI(repo, nil)
  448. result, err := api.RefreshIfNeeded(withOAuthRefreshRequestPath(context.Background()), account, executor, 3*time.Minute)
  449. require.ErrorContains(t, err, "account is not active")
  450. require.Nil(t, result)
  451. require.Zero(t, executor.refreshCalls)
  452. require.Zero(t, repo.updateCalls)
  453. }
  454. func TestRefreshIfNeeded_RequestPathDBRereadRevalidatesExecutorContract(t *testing.T) {
  455. tests := []struct {
  456. name string
  457. freshPlatform string
  458. freshType string
  459. }{
  460. {name: "platform changed", freshPlatform: PlatformAnthropic, freshType: AccountTypeOAuth},
  461. {name: "type changed", freshPlatform: PlatformGrok, freshType: AccountTypeUpstream},
  462. }
  463. for _, tt := range tests {
  464. t.Run(tt.name, func(t *testing.T) {
  465. account := &Account{ID: 83, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true}
  466. freshAccount := &Account{ID: account.ID, Platform: tt.freshPlatform, Type: tt.freshType, Status: StatusActive, Schedulable: true}
  467. repo := &refreshAPIAccountRepo{account: freshAccount}
  468. executor := NewGrokTokenRefresher(nil)
  469. api := NewOAuthRefreshAPI(repo, nil)
  470. result, err := api.RefreshIfNeeded(withOAuthRefreshRequestPath(context.Background()), account, executor, 3*time.Minute)
  471. require.ErrorIs(t, err, errOAuthRefreshAccountStateChanged)
  472. require.Nil(t, result)
  473. require.Zero(t, repo.updateCalls)
  474. })
  475. }
  476. }
  477. func TestRefreshIfNeeded_LocalLockWaitHonorsContext(t *testing.T) {
  478. account := &Account{ID: 80, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive}
  479. repo := &refreshAPIAccountRepo{account: account}
  480. executor := &refreshAPIExecutorStub{needsRefresh: true}
  481. api := NewOAuthRefreshAPI(repo, nil)
  482. lock := api.getLocalLock(executor.CacheKey(account))
  483. require.NoError(t, lock.Lock(context.Background()))
  484. defer lock.Unlock()
  485. ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
  486. defer cancel()
  487. result, err := api.RefreshIfNeeded(ctx, account, executor, time.Hour)
  488. require.ErrorIs(t, err, context.DeadlineExceeded)
  489. require.Nil(t, result)
  490. require.Zero(t, executor.refreshCalls)
  491. }
  492. func TestRefreshIfNeeded_ReleasesDistributedLockAfterParentCancellation(t *testing.T) {
  493. account := &Account{ID: 81, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive}
  494. repo := &refreshAPIAccountRepo{account: account}
  495. cache := &refreshAPICacheStub{lockResult: true}
  496. ctx, cancel := context.WithCancel(context.Background())
  497. executor := &refreshAPIExecutorStub{
  498. needsRefresh: true,
  499. err: errors.New("temporary provider error"),
  500. onRefresh: cancel,
  501. }
  502. api := NewOAuthRefreshAPI(repo, cache)
  503. _, err := api.RefreshIfNeeded(ctx, account, executor, time.Hour)
  504. require.Error(t, err)
  505. require.Equal(t, 1, cache.releaseCalls)
  506. require.NoError(t, cache.releaseCtxErr, "lock cleanup must not reuse the canceled attempt context")
  507. }
  508. func TestRefreshIfNeeded_RevalidatesFreshAccountBeforeRefresh(t *testing.T) {
  509. selected := &Account{ID: 82, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive}
  510. tests := []struct {
  511. name string
  512. fresh *Account
  513. }{
  514. {name: "converted to API key", fresh: &Account{ID: 82, Platform: PlatformGrok, Type: AccountTypeAPIKey, Status: StatusActive}},
  515. {name: "disabled", fresh: &Account{ID: 82, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusDisabled}},
  516. }
  517. for _, tt := range tests {
  518. t.Run(tt.name, func(t *testing.T) {
  519. repo := &refreshAPIAccountRepo{account: tt.fresh}
  520. executor := &refreshAPIExecutorStub{
  521. needsRefresh: true,
  522. canRefresh: func(account *Account) bool {
  523. return account.Platform == PlatformGrok && account.Type == AccountTypeOAuth
  524. },
  525. }
  526. api := NewOAuthRefreshAPI(repo, nil)
  527. result, err := api.RefreshIfNeeded(context.Background(), selected, executor, time.Hour)
  528. require.NoError(t, err)
  529. require.False(t, result.Refreshed)
  530. require.Zero(t, executor.refreshCalls)
  531. require.Zero(t, repo.updateCalls)
  532. })
  533. }
  534. }
  535. func TestRefreshIfNeeded_RequestPathDBRereadMissingGrokRefreshCredentialReturnsPermanentSignal(t *testing.T) {
  536. account := &Account{
  537. ID: 84,
  538. Platform: PlatformGrok,
  539. Type: AccountTypeOAuth,
  540. Status: StatusActive,
  541. Schedulable: true,
  542. Credentials: map[string]any{
  543. "refresh_token": "caller-snapshot-refresh-token",
  544. },
  545. }
  546. freshAccount := &Account{ID: account.ID, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true}
  547. repo := &refreshAPIAccountRepo{account: freshAccount}
  548. executor := NewGrokTokenRefresher(nil)
  549. api := NewOAuthRefreshAPI(repo, nil)
  550. result, err := api.RefreshIfNeeded(withOAuthRefreshRequestPath(context.Background()), account, executor, 3*time.Minute)
  551. require.ErrorIs(t, err, errGrokOAuthRefreshTokenMissing)
  552. require.Nil(t, result)
  553. require.Zero(t, repo.updateCalls)
  554. }
  555. func TestRefreshIfNeeded_LateSuccessAfterDeadlineDoesNotPersist(t *testing.T) {
  556. account := &Account{ID: 85, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive}
  557. repo := &refreshAPIAccountRepo{account: account}
  558. executor := &refreshAPIExecutorStub{
  559. needsRefresh: true,
  560. credentials: map[string]any{"access_token": "late-token"},
  561. delay: 30 * time.Millisecond,
  562. }
  563. api := NewOAuthRefreshAPI(repo, nil)
  564. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
  565. defer cancel()
  566. result, err := api.RefreshIfNeeded(ctx, account, executor, time.Hour)
  567. require.ErrorIs(t, err, context.DeadlineExceeded)
  568. require.Nil(t, result)
  569. require.Zero(t, repo.updateCredentialsCalls, "late credentials must not cross the unified API persistence boundary")
  570. }
  571. func TestRefreshIfNeeded_NilCredentials(t *testing.T) {
  572. account := &Account{ID: 9, Platform: PlatformGemini, Type: AccountTypeOAuth, Status: StatusActive}
  573. repo := &refreshAPIAccountRepo{account: account}
  574. cache := &refreshAPICacheStub{lockResult: true}
  575. executor := &refreshAPIExecutorStub{
  576. needsRefresh: true,
  577. credentials: nil, // Refresh returns nil credentials
  578. }
  579. api := NewOAuthRefreshAPI(repo, cache)
  580. result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
  581. require.NoError(t, err)
  582. require.True(t, result.Refreshed)
  583. require.Nil(t, result.NewCredentials)
  584. require.Equal(t, 0, repo.updateCalls) // no DB update when credentials are nil
  585. }
  586. // ========== MergeCredentials tests ==========
  587. func TestMergeCredentials_Basic(t *testing.T) {
  588. old := map[string]any{"a": "1", "b": "2", "c": "3"}
  589. new := map[string]any{"a": "new", "d": "4"}
  590. result := MergeCredentials(old, new)
  591. require.Equal(t, "new", result["a"]) // new value preserved
  592. require.Equal(t, "2", result["b"]) // old value kept
  593. require.Equal(t, "3", result["c"]) // old value kept
  594. require.Equal(t, "4", result["d"]) // new value preserved
  595. }
  596. func TestMergeCredentials_NilNew(t *testing.T) {
  597. old := map[string]any{"a": "1"}
  598. result := MergeCredentials(old, nil)
  599. require.NotNil(t, result)
  600. require.Equal(t, "1", result["a"])
  601. }
  602. func TestMergeCredentials_NilOld(t *testing.T) {
  603. new := map[string]any{"a": "1"}
  604. result := MergeCredentials(nil, new)
  605. require.Equal(t, "1", result["a"])
  606. }
  607. func TestMergeCredentials_BothNil(t *testing.T) {
  608. result := MergeCredentials(nil, nil)
  609. require.NotNil(t, result)
  610. require.Empty(t, result)
  611. }
  612. func TestMergeCredentials_NewOverridesOld(t *testing.T) {
  613. old := map[string]any{"access_token": "old-token", "refresh_token": "old-refresh"}
  614. new := map[string]any{"access_token": "new-token"}
  615. result := MergeCredentials(old, new)
  616. require.Equal(t, "new-token", result["access_token"]) // overridden
  617. require.Equal(t, "old-refresh", result["refresh_token"]) // preserved
  618. }
  619. // ========== BuildClaudeAccountCredentials tests ==========
  620. func TestBuildClaudeAccountCredentials_Full(t *testing.T) {
  621. tokenInfo := &TokenInfo{
  622. AccessToken: "at-123",
  623. TokenType: "Bearer",
  624. ExpiresIn: 3600,
  625. ExpiresAt: 1700000000,
  626. RefreshToken: "rt-456",
  627. Scope: "openid",
  628. }
  629. creds := BuildClaudeAccountCredentials(tokenInfo)
  630. require.Equal(t, "at-123", creds["access_token"])
  631. require.Equal(t, "Bearer", creds["token_type"])
  632. require.Equal(t, "3600", creds["expires_in"])
  633. require.Equal(t, "1700000000", creds["expires_at"])
  634. require.Equal(t, "rt-456", creds["refresh_token"])
  635. require.Equal(t, "openid", creds["scope"])
  636. }
  637. func TestBuildClaudeAccountCredentials_Minimal(t *testing.T) {
  638. tokenInfo := &TokenInfo{
  639. AccessToken: "at-789",
  640. TokenType: "Bearer",
  641. ExpiresIn: 7200,
  642. ExpiresAt: 1700003600,
  643. }
  644. creds := BuildClaudeAccountCredentials(tokenInfo)
  645. require.Equal(t, "at-789", creds["access_token"])
  646. require.Equal(t, "Bearer", creds["token_type"])
  647. require.Equal(t, "7200", creds["expires_in"])
  648. require.Equal(t, "1700003600", creds["expires_at"])
  649. _, hasRefresh := creds["refresh_token"]
  650. _, hasScope := creds["scope"]
  651. require.False(t, hasRefresh, "refresh_token should not be set when empty")
  652. require.False(t, hasScope, "scope should not be set when empty")
  653. }
  654. // refreshAPIAccountRepoWithRace supports returning a different account on subsequent GetByID calls
  655. // to simulate race conditions where another worker has refreshed the token.
  656. type refreshAPIAccountRepoWithRace struct {
  657. refreshAPIAccountRepo
  658. raceAccount *Account // returned on 2nd+ GetByID call
  659. getByIDCalls int
  660. }
  661. func (r *refreshAPIAccountRepoWithRace) GetByID(_ context.Context, _ int64) (*Account, error) {
  662. r.getByIDCalls++
  663. if r.getByIDCalls > 1 && r.raceAccount != nil {
  664. return activeRefreshAPITestAccount(r.raceAccount), nil
  665. }
  666. if r.getByIDErr != nil {
  667. return nil, r.getByIDErr
  668. }
  669. return activeRefreshAPITestAccount(r.account), nil
  670. }
  671. // ========== Race recovery tests ==========
  672. func TestRefreshIfNeeded_InvalidGrantRaceRecovered(t *testing.T) {
  673. // Account with old refresh token
  674. account := &Account{
  675. ID: 10,
  676. Platform: PlatformAnthropic,
  677. Type: AccountTypeOAuth,
  678. Status: StatusActive,
  679. Credentials: map[string]any{"refresh_token": "old-rt", "access_token": "old-at"},
  680. }
  681. // After race, DB has new refresh token from another worker
  682. racedAccount := &Account{
  683. ID: 10,
  684. Platform: PlatformAnthropic,
  685. Type: AccountTypeOAuth,
  686. Status: StatusActive,
  687. Credentials: map[string]any{"refresh_token": "new-rt", "access_token": "new-at"},
  688. }
  689. repo := &refreshAPIAccountRepoWithRace{
  690. refreshAPIAccountRepo: refreshAPIAccountRepo{account: account},
  691. raceAccount: racedAccount,
  692. }
  693. cache := &refreshAPICacheStub{lockResult: true}
  694. executor := &refreshAPIExecutorStub{
  695. needsRefresh: true,
  696. err: errors.New("invalid_grant: refresh token not found or invalid"),
  697. }
  698. api := NewOAuthRefreshAPI(repo, cache)
  699. result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
  700. require.NoError(t, err, "race-recovered invalid_grant should not return error")
  701. require.False(t, result.Refreshed)
  702. require.False(t, result.LockHeld)
  703. require.NotNil(t, result.Account)
  704. require.Equal(t, "new-rt", result.Account.GetCredential("refresh_token"))
  705. require.Equal(t, 0, repo.updateCalls) // no DB update needed, another worker did it
  706. }
  707. func TestRefreshIfNeeded_InvalidGrantGenuine(t *testing.T) {
  708. // Account with revoked refresh token - DB still has the same token
  709. account := &Account{
  710. ID: 11,
  711. Platform: PlatformAnthropic,
  712. Type: AccountTypeOAuth,
  713. Status: StatusActive,
  714. Credentials: map[string]any{"refresh_token": "revoked-rt", "access_token": "old-at"},
  715. }
  716. repo := &refreshAPIAccountRepoWithRace{
  717. refreshAPIAccountRepo: refreshAPIAccountRepo{account: account},
  718. raceAccount: account, // same refresh_token on re-read
  719. }
  720. cache := &refreshAPICacheStub{lockResult: true}
  721. executor := &refreshAPIExecutorStub{
  722. needsRefresh: true,
  723. err: errors.New("invalid_grant: refresh token revoked"),
  724. }
  725. api := NewOAuthRefreshAPI(repo, cache)
  726. result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
  727. require.Error(t, err, "genuine invalid_grant should propagate error")
  728. require.NotNil(t, result)
  729. require.NotNil(t, result.Account)
  730. require.Equal(t, "revoked-rt", result.Account.GetCredential("refresh_token"))
  731. require.Contains(t, err.Error(), "invalid_grant")
  732. }
  733. func TestRefreshIfNeeded_InvalidGrantDBRereadFailsOnRecovery(t *testing.T) {
  734. account := &Account{
  735. ID: 12,
  736. Platform: PlatformAnthropic,
  737. Type: AccountTypeOAuth,
  738. Status: StatusActive,
  739. Credentials: map[string]any{"refresh_token": "old-rt"},
  740. }
  741. repo := &refreshAPIAccountRepoWithRace{
  742. refreshAPIAccountRepo: refreshAPIAccountRepo{account: account},
  743. raceAccount: nil, // GetByID returns nil on recovery attempt
  744. }
  745. cache := &refreshAPICacheStub{lockResult: true}
  746. executor := &refreshAPIExecutorStub{
  747. needsRefresh: true,
  748. err: errors.New("invalid_grant"),
  749. }
  750. api := NewOAuthRefreshAPI(repo, cache)
  751. result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
  752. require.Error(t, err, "should propagate error when recovery DB re-read fails")
  753. require.NotNil(t, result)
  754. require.NotNil(t, result.Account)
  755. require.Equal(t, "old-rt", result.Account.GetCredential("refresh_token"))
  756. }
  757. func TestRefreshIfNeeded_LocalMutexSerializesConcurrent(t *testing.T) {
  758. // Test that two goroutines for the same account are serialized by the local mutex.
  759. // The first goroutine refreshes successfully; the second sees NeedsRefresh=false.
  760. refreshed := &Account{
  761. ID: 20,
  762. Platform: PlatformAnthropic,
  763. Type: AccountTypeOAuth,
  764. Status: StatusActive,
  765. Credentials: map[string]any{"refresh_token": "new-rt", "access_token": "new-at"},
  766. }
  767. callCount := 0
  768. repo := &refreshAPIAccountRepo{account: &Account{
  769. ID: 20,
  770. Platform: PlatformAnthropic,
  771. Type: AccountTypeOAuth,
  772. Status: StatusActive,
  773. Credentials: map[string]any{"refresh_token": "old-rt"},
  774. }}
  775. // After first refresh, NeedsRefresh should return false
  776. // We simulate this by using an executor that decrements needsRefresh after first call
  777. var mu sync.Mutex
  778. dynamicExecutor := &dynamicRefreshExecutor{
  779. canRefresh: true,
  780. cacheKey: "test:mutex:anthropic",
  781. refreshFunc: func(_ context.Context, _ *Account) (map[string]any, error) {
  782. mu.Lock()
  783. callCount++
  784. mu.Unlock()
  785. time.Sleep(50 * time.Millisecond) // slow refresh
  786. return map[string]any{"access_token": "new-at"}, nil
  787. },
  788. needsRefreshFunc: func() bool {
  789. mu.Lock()
  790. defer mu.Unlock()
  791. return callCount == 0 // only first call needs refresh
  792. },
  793. }
  794. _ = refreshed
  795. api := NewOAuthRefreshAPI(repo, nil) // no distributed lock, only local mutex
  796. var wg sync.WaitGroup
  797. results := make([]*OAuthRefreshResult, 2)
  798. errs := make([]error, 2)
  799. for i := 0; i < 2; i++ {
  800. wg.Add(1)
  801. go func(idx int) {
  802. defer wg.Done()
  803. results[idx], errs[idx] = api.RefreshIfNeeded(context.Background(), repo.account, dynamicExecutor, 3*time.Minute)
  804. }(i)
  805. }
  806. wg.Wait()
  807. require.NoError(t, errs[0])
  808. require.NoError(t, errs[1])
  809. // Only one goroutine should have actually called Refresh
  810. mu.Lock()
  811. require.Equal(t, 1, callCount, "only one refresh call should have been made")
  812. mu.Unlock()
  813. }
  814. func TestRefreshIfNeeded_LocalLockWaitHonorsContextCancellation(t *testing.T) {
  815. account := &Account{ID: 21, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive}
  816. repo := &refreshAPIAccountRepo{account: account}
  817. refreshStarted := make(chan struct{})
  818. releaseRefresh := make(chan struct{})
  819. var once sync.Once
  820. executor := &dynamicRefreshExecutor{
  821. canRefresh: true,
  822. cacheKey: "test:context-lock:grok",
  823. needsRefreshFunc: func() bool { return true },
  824. refreshFunc: func(context.Context, *Account) (map[string]any, error) {
  825. once.Do(func() { close(refreshStarted) })
  826. <-releaseRefresh
  827. return map[string]any{"access_token": "new-at"}, nil
  828. },
  829. }
  830. api := NewOAuthRefreshAPI(repo, nil)
  831. firstDone := make(chan error, 1)
  832. go func() {
  833. _, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
  834. firstDone <- err
  835. }()
  836. <-refreshStarted
  837. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
  838. defer cancel()
  839. startedAt := time.Now()
  840. result, err := api.RefreshIfNeeded(ctx, account, executor, 3*time.Minute)
  841. require.Nil(t, result)
  842. require.ErrorIs(t, err, context.DeadlineExceeded)
  843. require.Less(t, time.Since(startedAt), 500*time.Millisecond)
  844. close(releaseRefresh)
  845. require.NoError(t, <-firstDone)
  846. }
  847. func TestRefreshIfNeeded_ReleasesDistributedLockWithCleanupContext(t *testing.T) {
  848. account := &Account{
  849. ID: 22,
  850. Platform: PlatformGrok,
  851. Type: AccountTypeOAuth,
  852. Status: StatusActive,
  853. Credentials: map[string]any{
  854. "access_token": "old-access",
  855. "refresh_token": "old-refresh",
  856. },
  857. }
  858. repo := &refreshAPIAccountRepo{account: account}
  859. cache := &refreshAPICacheStub{lockResult: true}
  860. ctx, cancel := context.WithCancel(context.Background())
  861. executor := &dynamicRefreshExecutor{
  862. canRefresh: true,
  863. cacheKey: "test:cleanup:grok",
  864. needsRefreshFunc: func() bool { return true },
  865. refreshFunc: func(context.Context, *Account) (map[string]any, error) {
  866. cancel()
  867. return map[string]any{"access_token": "new-at"}, nil
  868. },
  869. }
  870. api := NewOAuthRefreshAPI(repo, cache)
  871. result, err := api.RefreshIfNeeded(ctx, account, executor, 3*time.Minute)
  872. require.ErrorIs(t, err, context.Canceled)
  873. require.Nil(t, result)
  874. require.Zero(t, repo.updateCalls)
  875. require.Equal(t, "old-access", account.GetGrokAccessToken())
  876. require.Zero(t, account.GetCredentialAsInt64("_token_version"))
  877. require.Equal(t, 1, cache.releaseCalls)
  878. require.NoError(t, cache.releaseCtxErr)
  879. }
  880. // dynamicRefreshExecutor is a test helper with function-based NeedsRefresh and Refresh.
  881. type dynamicRefreshExecutor struct {
  882. canRefresh bool
  883. cacheKey string
  884. needsRefreshFunc func() bool
  885. refreshFunc func(context.Context, *Account) (map[string]any, error)
  886. }
  887. func (e *dynamicRefreshExecutor) CanRefresh(_ *Account) bool { return e.canRefresh }
  888. func (e *dynamicRefreshExecutor) NeedsRefresh(_ *Account, _ time.Duration) bool {
  889. return e.needsRefreshFunc()
  890. }
  891. func (e *dynamicRefreshExecutor) Refresh(ctx context.Context, account *Account) (map[string]any, error) {
  892. return e.refreshFunc(ctx, account)
  893. }
  894. func (e *dynamicRefreshExecutor) CacheKey(_ *Account) string {
  895. return e.cacheKey
  896. }
  897. // ========== NewOAuthRefreshAPI TTL tests ==========
  898. func TestNewOAuthRefreshAPI_DefaultTTL(t *testing.T) {
  899. api := NewOAuthRefreshAPI(nil, nil)
  900. require.Equal(t, defaultRefreshLockTTL, api.lockTTL)
  901. }
  902. func TestNewOAuthRefreshAPI_CustomTTL(t *testing.T) {
  903. api := NewOAuthRefreshAPI(nil, nil, 90*time.Second)
  904. require.Equal(t, 90*time.Second, api.lockTTL)
  905. }
  906. func TestNewOAuthRefreshAPI_ZeroTTLUsesDefault(t *testing.T) {
  907. api := NewOAuthRefreshAPI(nil, nil, 0)
  908. require.Equal(t, defaultRefreshLockTTL, api.lockTTL)
  909. }
  910. // ========== isInvalidGrantError tests ==========
  911. func TestIsInvalidGrantError(t *testing.T) {
  912. require.True(t, isInvalidGrantError(errors.New("invalid_grant: token revoked")))
  913. require.True(t, isInvalidGrantError(errors.New("INVALID_GRANT")))
  914. require.False(t, isInvalidGrantError(errors.New("invalid_client")))
  915. require.False(t, isInvalidGrantError(nil))
  916. }
  917. // ========== BackgroundRefreshPolicy tests ==========
  918. func TestBackgroundRefreshPolicy_DefaultSkips(t *testing.T) {
  919. p := DefaultBackgroundRefreshPolicy()
  920. require.ErrorIs(t, p.handleLockHeld(), errRefreshSkipped)
  921. require.ErrorIs(t, p.handleAlreadyRefreshed(), errRefreshSkipped)
  922. }
  923. func TestBackgroundRefreshPolicy_SuccessOverride(t *testing.T) {
  924. p := BackgroundRefreshPolicy{
  925. OnLockHeld: BackgroundSkipAsSuccess,
  926. OnAlreadyRefresh: BackgroundSkipAsSuccess,
  927. }
  928. require.NoError(t, p.handleLockHeld())
  929. require.NoError(t, p.handleAlreadyRefreshed())
  930. }
  931. // ========== ProviderRefreshPolicy tests ==========
  932. func TestClaudeProviderRefreshPolicy(t *testing.T) {
  933. p := ClaudeProviderRefreshPolicy()
  934. require.Equal(t, ProviderRefreshErrorUseExistingToken, p.OnRefreshError)
  935. require.Equal(t, ProviderLockHeldWaitForCache, p.OnLockHeld)
  936. require.Equal(t, time.Minute, p.FailureTTL)
  937. }
  938. func TestOpenAIProviderRefreshPolicy(t *testing.T) {
  939. p := OpenAIProviderRefreshPolicy()
  940. require.Equal(t, ProviderRefreshErrorUseExistingToken, p.OnRefreshError)
  941. require.Equal(t, ProviderLockHeldWaitForCache, p.OnLockHeld)
  942. require.Equal(t, time.Minute, p.FailureTTL)
  943. }
  944. func TestGeminiProviderRefreshPolicy(t *testing.T) {
  945. p := GeminiProviderRefreshPolicy()
  946. require.Equal(t, ProviderRefreshErrorReturn, p.OnRefreshError)
  947. require.Equal(t, ProviderLockHeldUseExistingToken, p.OnLockHeld)
  948. require.Equal(t, time.Duration(0), p.FailureTTL)
  949. }
  950. func TestAntigravityProviderRefreshPolicy(t *testing.T) {
  951. p := AntigravityProviderRefreshPolicy()
  952. require.Equal(t, ProviderRefreshErrorReturn, p.OnRefreshError)
  953. require.Equal(t, ProviderLockHeldUseExistingToken, p.OnLockHeld)
  954. require.Equal(t, time.Duration(0), p.FailureTTL)
  955. }