token_refresh_service_test.go 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "reflect"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/stretchr/testify/require"
  11. )
  12. type tokenRefreshAccountRepo struct {
  13. mockAccountRepoForGemini
  14. updateCalls int
  15. fullUpdateCalls int
  16. updateCredentialsCalls int
  17. setErrorCalls int
  18. clearTempCalls int
  19. setTempUnschedCalls int
  20. updateExtraCalls int
  21. lastErrorMessage string
  22. lastTempUnschedReason string
  23. lastExtraUpdates map[string]any
  24. lastAccount *Account
  25. updateErr error
  26. cancelOnUpdate context.CancelFunc
  27. conditionalErrorCalls int
  28. conditionalTempCalls int
  29. conditionalSuccessCalls int
  30. conditionalErrorErr error
  31. conditionalTempErr error
  32. conditionalSuccessErr error
  33. snapshotReads bool
  34. respectReadContext bool
  35. getByIDCalls int
  36. durableReadDelay time.Duration
  37. mutateSchedulingOnSuccessCAS bool
  38. reauthorizeOnErrorCAS bool
  39. reauthorizeOnTempCAS bool
  40. repairProxyOnErrorCAS bool
  41. repairProxyOnTempCAS bool
  42. setErrorErr error
  43. setTempUnschedErr error
  44. beforeConditionalState func()
  45. }
  46. func (r *tokenRefreshAccountRepo) Update(ctx context.Context, account *Account) error {
  47. r.updateCalls++
  48. r.fullUpdateCalls++
  49. r.lastAccount = account
  50. return r.updateErr
  51. }
  52. func (r *tokenRefreshAccountRepo) UpdateCredentials(ctx context.Context, id int64, credentials map[string]any) error {
  53. r.updateCalls++
  54. r.updateCredentialsCalls++
  55. if r.updateErr != nil {
  56. return r.updateErr
  57. }
  58. cloned := shallowCopyMap(credentials)
  59. if r.accountsByID != nil {
  60. if acc, ok := r.accountsByID[id]; ok && acc != nil {
  61. acc.Credentials = cloned
  62. r.lastAccount = acc
  63. if r.cancelOnUpdate != nil {
  64. r.cancelOnUpdate()
  65. }
  66. return nil
  67. }
  68. }
  69. r.lastAccount = &Account{ID: id, Credentials: cloned}
  70. if r.cancelOnUpdate != nil {
  71. r.cancelOnUpdate()
  72. }
  73. return nil
  74. }
  75. func (r *tokenRefreshAccountRepo) GetByID(ctx context.Context, id int64) (*Account, error) {
  76. if r.respectReadContext && ctx.Err() != nil {
  77. return nil, ctx.Err()
  78. }
  79. r.getByIDCalls++
  80. if r.getByIDCalls > 1 && r.durableReadDelay > 0 {
  81. timer := time.NewTimer(r.durableReadDelay)
  82. defer timer.Stop()
  83. select {
  84. case <-ctx.Done():
  85. return nil, ctx.Err()
  86. case <-timer.C:
  87. }
  88. }
  89. account, err := r.mockAccountRepoForGemini.GetByID(ctx, id)
  90. if err != nil || !r.snapshotReads {
  91. return account, err
  92. }
  93. return snapshotOAuthRefreshAccount(account), nil
  94. }
  95. func (r *tokenRefreshAccountRepo) SetError(ctx context.Context, id int64, errorMsg string) error {
  96. r.setErrorCalls++
  97. r.lastErrorMessage = errorMsg
  98. return r.setErrorErr
  99. }
  100. func (r *tokenRefreshAccountRepo) ClearTempUnschedulable(ctx context.Context, id int64) error {
  101. r.clearTempCalls++
  102. return nil
  103. }
  104. func (r *tokenRefreshAccountRepo) SetTempUnschedulable(ctx context.Context, id int64, until time.Time, reason string) error {
  105. r.setTempUnschedCalls++
  106. r.lastTempUnschedReason = reason
  107. return r.setTempUnschedErr
  108. }
  109. func (r *tokenRefreshAccountRepo) SetGrokCredentialErrorIfMatch(
  110. _ context.Context,
  111. id int64,
  112. snapshot GrokCredentialMutationSnapshot,
  113. errorMsg string,
  114. ) (bool, error) {
  115. if r.beforeConditionalState != nil {
  116. hook := r.beforeConditionalState
  117. r.beforeConditionalState = nil
  118. hook()
  119. }
  120. account := r.accountsByID[id]
  121. if !grokCredentialSnapshotMatchesAccount(account, snapshot) ||
  122. (errorMsg == string(GrokCredentialReasonProxyInvalid) && account.Proxy != nil) {
  123. return false, nil
  124. }
  125. r.setErrorCalls++
  126. r.lastErrorMessage = errorMsg
  127. if r.setErrorErr != nil {
  128. return false, r.setErrorErr
  129. }
  130. account.Status = StatusError
  131. account.Schedulable = false
  132. account.ErrorMessage = errorMsg
  133. return true, nil
  134. }
  135. func (r *tokenRefreshAccountRepo) SetGrokCredentialTempUnschedulableIfMatch(
  136. _ context.Context,
  137. id int64,
  138. snapshot GrokCredentialMutationSnapshot,
  139. until time.Time,
  140. reason string,
  141. ) (bool, error) {
  142. if r.beforeConditionalState != nil {
  143. hook := r.beforeConditionalState
  144. r.beforeConditionalState = nil
  145. hook()
  146. }
  147. account := r.accountsByID[id]
  148. if !grokCredentialSnapshotMatchesAccount(account, snapshot) {
  149. return false, nil
  150. }
  151. r.setTempUnschedCalls++
  152. r.lastTempUnschedReason = reason
  153. if r.setTempUnschedErr != nil {
  154. return false, r.setTempUnschedErr
  155. }
  156. value := until
  157. account.TempUnschedulableUntil = &value
  158. return true, nil
  159. }
  160. func grokCredentialSnapshotMatchesAccount(account *Account, snapshot GrokCredentialMutationSnapshot) bool {
  161. return account != nil && account.IsGrokOAuth() && account.IsSchedulable() &&
  162. grokCredentialMutationSnapshot(account).CredentialsJSON == snapshot.CredentialsJSON &&
  163. grokCredentialProxyIDsEqual(account.ProxyID, snapshot.ProxyID)
  164. }
  165. func (r *tokenRefreshAccountRepo) SetGrokOAuthRefreshErrorIfCredentialsUnchanged(
  166. _ context.Context,
  167. id int64,
  168. expectedCredentials map[string]any,
  169. expectedProxyID *int64,
  170. errorMsg string,
  171. ) (bool, error) {
  172. r.conditionalErrorCalls++
  173. if r.conditionalErrorErr != nil {
  174. return false, r.conditionalErrorErr
  175. }
  176. account := r.accountsByID[id]
  177. if account == nil {
  178. return false, nil
  179. }
  180. if r.reauthorizeOnErrorCAS {
  181. r.reauthorizeOnErrorCAS = false
  182. account.Credentials = map[string]any{
  183. "access_token": "fresh-access",
  184. "refresh_token": "fresh-refresh",
  185. "_token_version": int64(2),
  186. }
  187. account.Status = StatusActive
  188. account.Schedulable = true
  189. }
  190. if r.repairProxyOnErrorCAS {
  191. r.repairProxyOnErrorCAS = false
  192. proxyID := int64(902)
  193. account.ProxyID = &proxyID
  194. }
  195. if account.Status != StatusActive || account.Platform != PlatformGrok || account.Type != AccountTypeOAuth ||
  196. !reflect.DeepEqual(account.Credentials, expectedCredentials) || !reflect.DeepEqual(account.ProxyID, expectedProxyID) {
  197. return false, nil
  198. }
  199. r.setErrorCalls++
  200. r.lastErrorMessage = errorMsg
  201. account.Status = StatusError
  202. account.Schedulable = false
  203. account.ErrorMessage = errorMsg
  204. return true, nil
  205. }
  206. func (r *tokenRefreshAccountRepo) UpdateGrokOAuthCredentialsIfUnchanged(
  207. _ context.Context,
  208. id int64,
  209. expectedCredentials map[string]any,
  210. expectedProxyID *int64,
  211. credentials map[string]any,
  212. ) (bool, error) {
  213. r.conditionalSuccessCalls++
  214. if r.conditionalSuccessErr != nil {
  215. return false, r.conditionalSuccessErr
  216. }
  217. account := r.accountsByID[id]
  218. if account != nil && r.mutateSchedulingOnSuccessCAS {
  219. r.mutateSchedulingOnSuccessCAS = false
  220. account.Status = StatusDisabled
  221. account.Schedulable = false
  222. resetAt := time.Now().Add(30 * time.Minute)
  223. account.RateLimitResetAt = &resetAt
  224. }
  225. if account == nil || account.Platform != PlatformGrok ||
  226. account.Type != AccountTypeOAuth || !reflect.DeepEqual(account.Credentials, expectedCredentials) ||
  227. !reflect.DeepEqual(account.ProxyID, expectedProxyID) {
  228. return false, nil
  229. }
  230. r.updateCalls++
  231. r.updateCredentialsCalls++
  232. account.Credentials = shallowCopyMap(credentials)
  233. r.lastAccount = account
  234. if r.cancelOnUpdate != nil {
  235. r.cancelOnUpdate()
  236. }
  237. return true, nil
  238. }
  239. func (r *tokenRefreshAccountRepo) SetGrokOAuthRefreshTempUnschedulableIfCredentialsUnchanged(
  240. _ context.Context,
  241. id int64,
  242. expectedCredentials map[string]any,
  243. expectedProxyID *int64,
  244. until time.Time,
  245. reason string,
  246. ) (bool, error) {
  247. r.conditionalTempCalls++
  248. if r.conditionalTempErr != nil {
  249. return false, r.conditionalTempErr
  250. }
  251. account := r.accountsByID[id]
  252. if account == nil {
  253. return false, nil
  254. }
  255. if r.reauthorizeOnTempCAS {
  256. r.reauthorizeOnTempCAS = false
  257. account.Credentials = map[string]any{
  258. "access_token": "fresh-access",
  259. "refresh_token": "fresh-refresh",
  260. "_token_version": int64(2),
  261. }
  262. account.Status = StatusActive
  263. account.Schedulable = true
  264. }
  265. if r.repairProxyOnTempCAS {
  266. r.repairProxyOnTempCAS = false
  267. proxyID := int64(902)
  268. account.ProxyID = &proxyID
  269. }
  270. if account.Status != StatusActive || account.Platform != PlatformGrok || account.Type != AccountTypeOAuth ||
  271. !reflect.DeepEqual(account.Credentials, expectedCredentials) || !reflect.DeepEqual(account.ProxyID, expectedProxyID) {
  272. return false, nil
  273. }
  274. r.setTempUnschedCalls++
  275. r.lastTempUnschedReason = reason
  276. account.TempUnschedulableUntil = &until
  277. account.TempUnschedulableReason = reason
  278. return true, nil
  279. }
  280. func (r *tokenRefreshAccountRepo) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error {
  281. r.updateExtraCalls++
  282. r.lastExtraUpdates = shallowCopyMap(updates)
  283. if r.accountsByID != nil {
  284. if acc, ok := r.accountsByID[id]; ok && acc != nil {
  285. if acc.Extra == nil {
  286. acc.Extra = make(map[string]any, len(updates))
  287. }
  288. for k, v := range updates {
  289. acc.Extra[k] = v
  290. }
  291. }
  292. }
  293. return nil
  294. }
  295. type tokenCacheInvalidatorStub struct {
  296. calls int
  297. err error
  298. ctxErr error
  299. lastAccount *Account
  300. }
  301. type tokenRefreshRuntimeBlocker struct {
  302. blockCalls int
  303. clearCalls int
  304. }
  305. func (b *tokenRefreshRuntimeBlocker) BlockAccountScheduling(*Account, time.Time, string) {
  306. b.blockCalls++
  307. }
  308. func (b *tokenRefreshRuntimeBlocker) ClearAccountSchedulingBlock(int64) {
  309. b.clearCalls++
  310. }
  311. func (s *tokenCacheInvalidatorStub) InvalidateToken(ctx context.Context, account *Account) error {
  312. s.calls++
  313. s.ctxErr = ctx.Err()
  314. s.lastAccount = snapshotOAuthRefreshAccount(account)
  315. return s.err
  316. }
  317. type tokenRefreshSchedulerCache struct {
  318. SchedulerCache
  319. setAccountCalls int
  320. ctxErr error
  321. lastAccount *Account
  322. }
  323. func (s *tokenRefreshSchedulerCache) SetAccount(ctx context.Context, account *Account) error {
  324. s.setAccountCalls++
  325. s.ctxErr = ctx.Err()
  326. s.lastAccount = snapshotOAuthRefreshAccount(account)
  327. return nil
  328. }
  329. type tempUnschedCacheStub struct {
  330. deleteCalls int
  331. setCalls int
  332. lastState *TempUnschedState
  333. }
  334. func (s *tempUnschedCacheStub) SetTempUnsched(ctx context.Context, accountID int64, state *TempUnschedState) error {
  335. s.setCalls++
  336. s.lastState = state
  337. return nil
  338. }
  339. func (s *tempUnschedCacheStub) GetTempUnsched(ctx context.Context, accountID int64) (*TempUnschedState, error) {
  340. return nil, nil
  341. }
  342. func (s *tempUnschedCacheStub) DeleteTempUnsched(ctx context.Context, accountID int64) error {
  343. s.deleteCalls++
  344. return nil
  345. }
  346. type tokenRefresherStub struct {
  347. credentials map[string]any
  348. err error
  349. calls int
  350. }
  351. func (r *tokenRefresherStub) CanRefresh(account *Account) bool {
  352. return true
  353. }
  354. func (r *tokenRefresherStub) NeedsRefresh(account *Account, refreshWindowDuration time.Duration) bool {
  355. return true
  356. }
  357. func (r *tokenRefresherStub) Refresh(ctx context.Context, account *Account) (map[string]any, error) {
  358. r.calls++
  359. if r.err != nil {
  360. return nil, r.err
  361. }
  362. return r.credentials, nil
  363. }
  364. func (r *tokenRefresherStub) CacheKey(account *Account) string {
  365. return "test:stub:" + account.Platform
  366. }
  367. func TestTokenRefreshService_RefreshWithRetry_InvalidatesCache(t *testing.T) {
  368. repo := &tokenRefreshAccountRepo{}
  369. invalidator := &tokenCacheInvalidatorStub{}
  370. cfg := &config.Config{
  371. TokenRefresh: config.TokenRefreshConfig{
  372. MaxRetries: 1,
  373. RetryBackoffSeconds: 0,
  374. },
  375. }
  376. service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg, nil)
  377. account := &Account{
  378. ID: 5,
  379. Platform: PlatformGemini,
  380. Type: AccountTypeOAuth,
  381. }
  382. refresher := &tokenRefresherStub{
  383. credentials: map[string]any{
  384. "access_token": "new-token",
  385. },
  386. }
  387. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  388. require.NoError(t, err)
  389. require.Equal(t, 1, repo.updateCalls)
  390. require.Equal(t, 1, repo.updateCredentialsCalls)
  391. require.Equal(t, 0, repo.fullUpdateCalls)
  392. require.Equal(t, 1, invalidator.calls)
  393. require.Equal(t, "new-token", account.GetCredential("access_token"))
  394. }
  395. func TestTokenRefreshService_RefreshWithRetry_InvalidatorErrorIgnored(t *testing.T) {
  396. repo := &tokenRefreshAccountRepo{}
  397. invalidator := &tokenCacheInvalidatorStub{err: errors.New("invalidate failed")}
  398. cfg := &config.Config{
  399. TokenRefresh: config.TokenRefreshConfig{
  400. MaxRetries: 1,
  401. RetryBackoffSeconds: 0,
  402. },
  403. }
  404. service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg, nil)
  405. account := &Account{
  406. ID: 6,
  407. Platform: PlatformGemini,
  408. Type: AccountTypeOAuth,
  409. }
  410. refresher := &tokenRefresherStub{
  411. credentials: map[string]any{
  412. "access_token": "token",
  413. },
  414. }
  415. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  416. require.NoError(t, err)
  417. require.Equal(t, 1, repo.updateCalls)
  418. require.Equal(t, 1, invalidator.calls)
  419. }
  420. func TestTokenRefreshService_RefreshWithRetry_NilInvalidator(t *testing.T) {
  421. repo := &tokenRefreshAccountRepo{}
  422. cfg := &config.Config{
  423. TokenRefresh: config.TokenRefreshConfig{
  424. MaxRetries: 1,
  425. RetryBackoffSeconds: 0,
  426. },
  427. }
  428. service := NewTokenRefreshService(repo, nil, nil, nil, nil, nil, nil, cfg, nil)
  429. account := &Account{
  430. ID: 7,
  431. Platform: PlatformGemini,
  432. Type: AccountTypeOAuth,
  433. }
  434. refresher := &tokenRefresherStub{
  435. credentials: map[string]any{
  436. "access_token": "token",
  437. },
  438. }
  439. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  440. require.NoError(t, err)
  441. require.Equal(t, 1, repo.updateCalls)
  442. }
  443. // TestTokenRefreshService_RefreshWithRetry_Antigravity 测试 Antigravity 平台的缓存失效
  444. func TestTokenRefreshService_RefreshWithRetry_Antigravity(t *testing.T) {
  445. repo := &tokenRefreshAccountRepo{}
  446. invalidator := &tokenCacheInvalidatorStub{}
  447. cfg := &config.Config{
  448. TokenRefresh: config.TokenRefreshConfig{
  449. MaxRetries: 1,
  450. RetryBackoffSeconds: 0,
  451. },
  452. }
  453. service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg, nil)
  454. account := &Account{
  455. ID: 8,
  456. Platform: PlatformAntigravity,
  457. Type: AccountTypeOAuth,
  458. }
  459. refresher := &tokenRefresherStub{
  460. credentials: map[string]any{
  461. "access_token": "ag-token",
  462. },
  463. }
  464. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  465. require.NoError(t, err)
  466. require.Equal(t, 1, repo.updateCalls)
  467. require.Equal(t, 1, invalidator.calls) // Antigravity 也应触发缓存失效
  468. }
  469. func TestAntigravityTokenRefresher_NeedsRefresh_ForceRefreshMarker(t *testing.T) {
  470. refresher := NewAntigravityTokenRefresher(nil)
  471. account := &Account{
  472. ID: 3675,
  473. Platform: PlatformAntigravity,
  474. Type: AccountTypeOAuth,
  475. Credentials: map[string]any{
  476. "expires_at": time.Now().Add(time.Hour).Format(time.RFC3339),
  477. },
  478. Extra: map[string]any{
  479. antigravityForceTokenRefreshExtraKey: true,
  480. },
  481. }
  482. require.True(t, refresher.NeedsRefresh(account, 0), "server-invalidated token must refresh even before expires_at")
  483. }
  484. func TestAntigravityTokenRefresher_NeedsRefresh_NormalExpiryRulesUnchanged(t *testing.T) {
  485. refresher := NewAntigravityTokenRefresher(nil)
  486. t.Run("normal_unexpired_without_marker_does_not_refresh", func(t *testing.T) {
  487. account := &Account{
  488. ID: 3707,
  489. Platform: PlatformAntigravity,
  490. Type: AccountTypeOAuth,
  491. Credentials: map[string]any{
  492. "expires_at": time.Now().Add(time.Hour).Format(time.RFC3339),
  493. },
  494. }
  495. require.False(t, refresher.NeedsRefresh(account, 0))
  496. })
  497. t.Run("normal_expiring_refreshes", func(t *testing.T) {
  498. account := &Account{
  499. ID: 3708,
  500. Platform: PlatformAntigravity,
  501. Type: AccountTypeOAuth,
  502. Credentials: map[string]any{
  503. "expires_at": time.Now().Add(5 * time.Minute).Format(time.RFC3339),
  504. },
  505. }
  506. require.True(t, refresher.NeedsRefresh(account, 0))
  507. })
  508. }
  509. func TestTokenRefreshService_RefreshWithRetry_AntigravityClearsForceRefreshOnSuccess(t *testing.T) {
  510. repo := &tokenRefreshAccountRepo{}
  511. cfg := &config.Config{
  512. TokenRefresh: config.TokenRefreshConfig{
  513. MaxRetries: 1,
  514. RetryBackoffSeconds: 0,
  515. },
  516. }
  517. service := NewTokenRefreshService(repo, nil, nil, nil, nil, nil, nil, cfg, nil)
  518. until := time.Now().Add(10 * time.Minute)
  519. account := &Account{
  520. ID: 3709,
  521. Platform: PlatformAntigravity,
  522. Type: AccountTypeOAuth,
  523. TempUnschedulableUntil: &until,
  524. Extra: map[string]any{
  525. antigravityForceTokenRefreshExtraKey: true,
  526. antigravityForceTokenRefreshReasonExtraKey: "401_invalid",
  527. "privacy_mode": AntigravityPrivacySet,
  528. },
  529. }
  530. refresher := &tokenRefresherStub{
  531. credentials: map[string]any{
  532. "access_token": "new-ag-token",
  533. },
  534. }
  535. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  536. require.NoError(t, err)
  537. require.Equal(t, 1, repo.updateCredentialsCalls)
  538. require.Equal(t, 1, repo.updateExtraCalls)
  539. require.Equal(t, false, repo.lastExtraUpdates[antigravityForceTokenRefreshExtraKey])
  540. require.Equal(t, "", repo.lastExtraUpdates[antigravityForceTokenRefreshReasonExtraKey])
  541. require.Equal(t, false, account.Extra[antigravityForceTokenRefreshExtraKey])
  542. require.Equal(t, 1, repo.clearTempCalls, "successful refresh should restore schedulability")
  543. }
  544. func TestTokenRefreshService_RefreshWithRetry_AntigravityForceRefreshInvalidGrantSetsError(t *testing.T) {
  545. repo := &tokenRefreshAccountRepo{}
  546. cfg := &config.Config{
  547. TokenRefresh: config.TokenRefreshConfig{
  548. MaxRetries: 3,
  549. RetryBackoffSeconds: 0,
  550. },
  551. }
  552. service := NewTokenRefreshService(repo, nil, nil, nil, nil, nil, nil, cfg, nil)
  553. account := &Account{
  554. ID: 3710,
  555. Platform: PlatformAntigravity,
  556. Type: AccountTypeOAuth,
  557. Extra: map[string]any{
  558. antigravityForceTokenRefreshExtraKey: true,
  559. antigravityForceTokenRefreshReasonExtraKey: "401_invalid",
  560. },
  561. }
  562. refresher := &tokenRefresherStub{
  563. err: errors.New("invalid_grant: token revoked"),
  564. }
  565. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  566. require.Error(t, err)
  567. require.Equal(t, 1, repo.setErrorCalls)
  568. require.Equal(t, 0, repo.setTempUnschedCalls)
  569. require.Equal(t, 1, repo.updateExtraCalls)
  570. require.Equal(t, false, repo.lastExtraUpdates[antigravityForceTokenRefreshExtraKey])
  571. require.Contains(t, repo.lastErrorMessage, "non-retryable")
  572. }
  573. // TestTokenRefreshService_RefreshWithRetry_NonOAuthAccount 测试非 OAuth 账号不触发缓存失效
  574. func TestTokenRefreshService_RefreshWithRetry_NonOAuthAccount(t *testing.T) {
  575. repo := &tokenRefreshAccountRepo{}
  576. invalidator := &tokenCacheInvalidatorStub{}
  577. cfg := &config.Config{
  578. TokenRefresh: config.TokenRefreshConfig{
  579. MaxRetries: 1,
  580. RetryBackoffSeconds: 0,
  581. },
  582. }
  583. service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg, nil)
  584. account := &Account{
  585. ID: 9,
  586. Platform: PlatformGemini,
  587. Type: AccountTypeAPIKey, // 非 OAuth
  588. }
  589. refresher := &tokenRefresherStub{
  590. credentials: map[string]any{
  591. "access_token": "token",
  592. },
  593. }
  594. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  595. require.NoError(t, err)
  596. require.Equal(t, 1, repo.updateCalls)
  597. require.Equal(t, 0, invalidator.calls) // 非 OAuth 不触发缓存失效
  598. }
  599. // TestTokenRefreshService_RefreshWithRetry_OtherPlatformOAuth 测试所有 OAuth 平台都触发缓存失效
  600. func TestTokenRefreshService_RefreshWithRetry_OtherPlatformOAuth(t *testing.T) {
  601. repo := &tokenRefreshAccountRepo{}
  602. invalidator := &tokenCacheInvalidatorStub{}
  603. cfg := &config.Config{
  604. TokenRefresh: config.TokenRefreshConfig{
  605. MaxRetries: 1,
  606. RetryBackoffSeconds: 0,
  607. },
  608. }
  609. service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg, nil)
  610. account := &Account{
  611. ID: 10,
  612. Platform: PlatformOpenAI, // OpenAI OAuth 账户
  613. Type: AccountTypeOAuth,
  614. }
  615. refresher := &tokenRefresherStub{
  616. credentials: map[string]any{
  617. "access_token": "token",
  618. },
  619. }
  620. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  621. require.NoError(t, err)
  622. require.Equal(t, 1, repo.updateCalls)
  623. require.Equal(t, 1, repo.updateCredentialsCalls)
  624. require.Equal(t, 1, invalidator.calls) // 所有 OAuth 账户刷新后触发缓存失效
  625. }
  626. func TestTokenRefreshService_RefreshWithRetry_UsesCredentialsUpdater(t *testing.T) {
  627. repo := &tokenRefreshAccountRepo{}
  628. cfg := &config.Config{
  629. TokenRefresh: config.TokenRefreshConfig{
  630. MaxRetries: 1,
  631. RetryBackoffSeconds: 0,
  632. },
  633. }
  634. service := NewTokenRefreshService(repo, nil, nil, nil, nil, nil, nil, cfg, nil)
  635. resetAt := time.Now().Add(30 * time.Minute)
  636. account := &Account{
  637. ID: 17,
  638. Platform: PlatformOpenAI,
  639. Type: AccountTypeOAuth,
  640. RateLimitResetAt: &resetAt,
  641. Credentials: map[string]any{
  642. "access_token": "old-token",
  643. },
  644. }
  645. refresher := &tokenRefresherStub{
  646. credentials: map[string]any{
  647. "access_token": "new-token",
  648. },
  649. }
  650. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  651. require.NoError(t, err)
  652. require.Equal(t, 1, repo.updateCredentialsCalls)
  653. require.Equal(t, 0, repo.fullUpdateCalls)
  654. require.NotNil(t, account.RateLimitResetAt)
  655. require.WithinDuration(t, resetAt, *account.RateLimitResetAt, time.Second)
  656. }
  657. // TestTokenRefreshService_RefreshWithRetry_UpdateFailed 测试更新失败的情况
  658. func TestTokenRefreshService_RefreshWithRetry_UpdateFailed(t *testing.T) {
  659. repo := &tokenRefreshAccountRepo{updateErr: errors.New("update failed")}
  660. invalidator := &tokenCacheInvalidatorStub{}
  661. cfg := &config.Config{
  662. TokenRefresh: config.TokenRefreshConfig{
  663. MaxRetries: 1,
  664. RetryBackoffSeconds: 0,
  665. },
  666. }
  667. service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg, nil)
  668. account := &Account{
  669. ID: 11,
  670. Platform: PlatformGemini,
  671. Type: AccountTypeOAuth,
  672. }
  673. refresher := &tokenRefresherStub{
  674. credentials: map[string]any{
  675. "access_token": "token",
  676. },
  677. }
  678. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  679. require.Error(t, err)
  680. require.Contains(t, err.Error(), "failed to save credentials")
  681. require.Equal(t, 1, repo.updateCalls)
  682. require.Equal(t, 0, invalidator.calls) // 更新失败时不应触发缓存失效
  683. }
  684. // TestTokenRefreshService_RefreshWithRetry_RefreshFailed 测试可重试错误耗尽不标记 error
  685. func TestTokenRefreshService_RefreshWithRetry_RefreshFailed(t *testing.T) {
  686. repo := &tokenRefreshAccountRepo{}
  687. invalidator := &tokenCacheInvalidatorStub{}
  688. cfg := &config.Config{
  689. TokenRefresh: config.TokenRefreshConfig{
  690. MaxRetries: 2,
  691. RetryBackoffSeconds: 0,
  692. },
  693. }
  694. service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg, nil)
  695. account := &Account{
  696. ID: 12,
  697. Platform: PlatformGemini,
  698. Type: AccountTypeOAuth,
  699. }
  700. refresher := &tokenRefresherStub{
  701. err: errors.New("refresh failed"),
  702. }
  703. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  704. require.Error(t, err)
  705. require.Equal(t, 0, repo.updateCalls) // 刷新失败不应更新
  706. require.Equal(t, 0, invalidator.calls) // 刷新失败不应触发缓存失效
  707. require.Equal(t, 0, repo.setErrorCalls) // 可重试错误耗尽不标记 error,下个周期继续重试
  708. }
  709. // TestTokenRefreshService_RefreshWithRetry_AntigravityRefreshFailed 测试 Antigravity 刷新失败不设置错误状态
  710. func TestTokenRefreshService_RefreshWithRetry_AntigravityRefreshFailed(t *testing.T) {
  711. repo := &tokenRefreshAccountRepo{}
  712. invalidator := &tokenCacheInvalidatorStub{}
  713. cfg := &config.Config{
  714. TokenRefresh: config.TokenRefreshConfig{
  715. MaxRetries: 1,
  716. RetryBackoffSeconds: 0,
  717. },
  718. }
  719. service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg, nil)
  720. account := &Account{
  721. ID: 13,
  722. Platform: PlatformAntigravity,
  723. Type: AccountTypeOAuth,
  724. }
  725. refresher := &tokenRefresherStub{
  726. err: errors.New("network error"), // 可重试错误
  727. }
  728. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  729. require.Error(t, err)
  730. require.Equal(t, 0, repo.updateCalls)
  731. require.Equal(t, 0, invalidator.calls)
  732. require.Equal(t, 0, repo.setErrorCalls) // Antigravity 可重试错误不设置错误状态
  733. }
  734. // TestTokenRefreshService_RefreshWithRetry_AntigravityNonRetryableError 测试 Antigravity 不可重试错误
  735. func TestTokenRefreshService_RefreshWithRetry_AntigravityNonRetryableError(t *testing.T) {
  736. repo := &tokenRefreshAccountRepo{}
  737. invalidator := &tokenCacheInvalidatorStub{}
  738. cfg := &config.Config{
  739. TokenRefresh: config.TokenRefreshConfig{
  740. MaxRetries: 3,
  741. RetryBackoffSeconds: 0,
  742. },
  743. }
  744. service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg, nil)
  745. account := &Account{
  746. ID: 14,
  747. Platform: PlatformAntigravity,
  748. Type: AccountTypeOAuth,
  749. }
  750. refresher := &tokenRefresherStub{
  751. err: errors.New("invalid_grant: token revoked"), // 不可重试错误
  752. }
  753. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  754. require.Error(t, err)
  755. require.Equal(t, 0, repo.updateCalls)
  756. require.Equal(t, 1, invalidator.calls)
  757. require.Equal(t, 1, repo.setErrorCalls) // 不可重试错误应设置错误状态
  758. }
  759. // TestTokenRefreshService_RefreshWithRetry_ClearsTempUnschedulable 测试刷新成功后清除临时不可调度(DB + Redis)
  760. func TestTokenRefreshService_RefreshWithRetry_ClearsTempUnschedulable(t *testing.T) {
  761. repo := &tokenRefreshAccountRepo{}
  762. invalidator := &tokenCacheInvalidatorStub{}
  763. tempCache := &tempUnschedCacheStub{}
  764. cfg := &config.Config{
  765. TokenRefresh: config.TokenRefreshConfig{
  766. MaxRetries: 1,
  767. RetryBackoffSeconds: 0,
  768. },
  769. }
  770. service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg, tempCache)
  771. until := time.Now().Add(10 * time.Minute)
  772. account := &Account{
  773. ID: 15,
  774. Platform: PlatformGemini,
  775. Type: AccountTypeOAuth,
  776. TempUnschedulableUntil: &until,
  777. }
  778. refresher := &tokenRefresherStub{
  779. credentials: map[string]any{
  780. "access_token": "new-token",
  781. },
  782. }
  783. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  784. require.NoError(t, err)
  785. require.Equal(t, 1, repo.updateCalls)
  786. require.Equal(t, 1, repo.clearTempCalls) // DB 清除
  787. require.Equal(t, 1, tempCache.deleteCalls) // Redis 缓存也应清除
  788. }
  789. // TestTokenRefreshService_RefreshWithRetry_NonRetryableErrorAllPlatforms 测试所有平台不可重试错误都 SetError
  790. func TestTokenRefreshService_RefreshWithRetry_NonRetryableErrorAllPlatforms(t *testing.T) {
  791. tests := []struct {
  792. name string
  793. platform string
  794. }{
  795. {name: "gemini", platform: PlatformGemini},
  796. {name: "anthropic", platform: PlatformAnthropic},
  797. {name: "openai", platform: PlatformOpenAI},
  798. {name: "antigravity", platform: PlatformAntigravity},
  799. }
  800. for _, tt := range tests {
  801. t.Run(tt.name, func(t *testing.T) {
  802. repo := &tokenRefreshAccountRepo{}
  803. invalidator := &tokenCacheInvalidatorStub{}
  804. cfg := &config.Config{
  805. TokenRefresh: config.TokenRefreshConfig{
  806. MaxRetries: 3,
  807. RetryBackoffSeconds: 0,
  808. },
  809. }
  810. service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg, nil)
  811. account := &Account{
  812. ID: 16,
  813. Platform: tt.platform,
  814. Type: AccountTypeOAuth,
  815. }
  816. refresher := &tokenRefresherStub{
  817. err: errors.New("invalid_grant: token revoked"),
  818. }
  819. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  820. require.Error(t, err)
  821. require.Equal(t, 1, repo.setErrorCalls) // 所有平台不可重试错误都应 SetError
  822. })
  823. }
  824. }
  825. func TestTokenRefreshService_RefreshWithRetry_NoRefreshTokenDoesNotTempUnschedule(t *testing.T) {
  826. repo := &tokenRefreshAccountRepo{}
  827. cfg := &config.Config{
  828. TokenRefresh: config.TokenRefreshConfig{
  829. MaxRetries: 2,
  830. RetryBackoffSeconds: 0,
  831. },
  832. }
  833. service := NewTokenRefreshService(repo, nil, nil, nil, nil, nil, nil, cfg, nil)
  834. account := &Account{
  835. ID: 18,
  836. Platform: PlatformOpenAI,
  837. Type: AccountTypeOAuth,
  838. }
  839. refresher := &tokenRefresherStub{
  840. err: errors.New("no refresh token available"),
  841. }
  842. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  843. require.Error(t, err)
  844. require.Equal(t, 0, repo.updateCalls)
  845. require.Equal(t, 0, repo.setTempUnschedCalls, "missing refresh token should not mark the account temp unschedulable")
  846. require.Equal(t, 1, repo.setErrorCalls, "missing refresh token should be treated as a non-retryable credential state")
  847. }
  848. // TestIsNonRetryableRefreshError 测试不可重试错误判断
  849. func TestIsNonRetryableRefreshError(t *testing.T) {
  850. tests := []struct {
  851. name string
  852. err error
  853. expected bool
  854. }{
  855. {name: "nil_error", err: nil, expected: false},
  856. {name: "network_error", err: errors.New("network timeout"), expected: false},
  857. {name: "invalid_grant", err: errors.New("invalid_grant"), expected: true},
  858. {name: "invalid_client", err: errors.New("invalid_client"), expected: true},
  859. {name: "invalid_refresh_token", err: errors.New(`OPENAI_OAUTH_TOKEN_REFRESH_FAILED: token refresh failed: status 401, body: {"error":{"code":"invalid_refresh_token"}}`), expected: true},
  860. {name: "token_expired", err: errors.New(`OPENAI_OAUTH_TOKEN_REFRESH_FAILED: token refresh failed: status 401, body: {"error":{"code":"token_expired"}}`), expected: true},
  861. {name: "refresh_token_reused", err: errors.New(`OPENAI_OAUTH_TOKEN_REFRESH_FAILED: token refresh failed: status 401, body: {"error":{"code":"refresh_token_reused"}}`), expected: true},
  862. {name: "app_session_terminated", err: errors.New(`OPENAI_OAUTH_TOKEN_REFRESH_FAILED: token refresh failed: status 401, body: {"error": {"code": "app_session_terminated"}}`), expected: true},
  863. {name: "unauthorized_client", err: errors.New("unauthorized_client"), expected: true},
  864. {name: "access_denied", err: errors.New("access_denied"), expected: true},
  865. {name: "no_refresh_token", err: errors.New("no refresh token available"), expected: true},
  866. {name: "grok_entitlement_denied", err: errors.New("GROK_OAUTH_ENTITLEMENT_DENIED: subscription required"), expected: true},
  867. {name: "invalid_scope", err: errors.New("invalid_scope: requested scope is not allowed"), expected: true},
  868. {name: "invalid_grant_with_desc", err: errors.New("Error: invalid_grant - token revoked"), expected: true},
  869. {name: "case_insensitive", err: errors.New("INVALID_GRANT"), expected: true},
  870. }
  871. for _, tt := range tests {
  872. t.Run(tt.name, func(t *testing.T) {
  873. result := isNonRetryableRefreshError(tt.err)
  874. require.Equal(t, tt.expected, result)
  875. })
  876. }
  877. }
  878. // ========== Path A (refreshAPI) 测试用例 ==========
  879. // mockTokenCacheForRefreshAPI 用于 Path A 测试的 GeminiTokenCache mock
  880. type mockTokenCacheForRefreshAPI struct {
  881. lockResult bool
  882. lockErr error
  883. releaseCalls int
  884. deleteCalls int
  885. deleteCtxErr error
  886. }
  887. func (m *mockTokenCacheForRefreshAPI) GetAccessToken(_ context.Context, _ string) (string, error) {
  888. return "", errors.New("not cached")
  889. }
  890. func (m *mockTokenCacheForRefreshAPI) SetAccessToken(_ context.Context, _ string, _ string, _ time.Duration) error {
  891. return nil
  892. }
  893. func (m *mockTokenCacheForRefreshAPI) DeleteAccessToken(ctx context.Context, _ string) error {
  894. m.deleteCalls++
  895. m.deleteCtxErr = ctx.Err()
  896. return nil
  897. }
  898. func (m *mockTokenCacheForRefreshAPI) AcquireRefreshLock(_ context.Context, _ string, _ time.Duration) (bool, error) {
  899. return m.lockResult, m.lockErr
  900. }
  901. func (m *mockTokenCacheForRefreshAPI) ReleaseRefreshLock(_ context.Context, _ string) error {
  902. m.releaseCalls++
  903. return nil
  904. }
  905. // buildPathAService 构建注入了 refreshAPI 的 service(Path A 测试辅助)
  906. func buildPathAService(repo *tokenRefreshAccountRepo, cache GeminiTokenCache, invalidator TokenCacheInvalidator) (*TokenRefreshService, *tokenRefresherStub) {
  907. for _, account := range repo.accountsByID {
  908. if account != nil && account.Status == "" {
  909. account.Status = StatusActive
  910. }
  911. }
  912. cfg := &config.Config{
  913. TokenRefresh: config.TokenRefreshConfig{
  914. MaxRetries: 1,
  915. RetryBackoffSeconds: 0,
  916. },
  917. }
  918. service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg, nil)
  919. refreshAPI := NewOAuthRefreshAPI(repo, cache)
  920. service.SetRefreshAPI(refreshAPI)
  921. refresher := &tokenRefresherStub{
  922. credentials: map[string]any{
  923. "access_token": "refreshed-token",
  924. },
  925. }
  926. return service, refresher
  927. }
  928. // TestPathA_Success 统一 API 路径正常成功:刷新 + DB 更新 + postRefreshActions
  929. func TestPathA_Success(t *testing.T) {
  930. account := &Account{
  931. ID: 100,
  932. Platform: PlatformGemini,
  933. Type: AccountTypeOAuth,
  934. Status: StatusActive,
  935. }
  936. repo := &tokenRefreshAccountRepo{}
  937. repo.accountsByID = map[int64]*Account{account.ID: account}
  938. invalidator := &tokenCacheInvalidatorStub{}
  939. cache := &mockTokenCacheForRefreshAPI{lockResult: true}
  940. service, refresher := buildPathAService(repo, cache, invalidator)
  941. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  942. require.NoError(t, err)
  943. require.Equal(t, 1, repo.updateCalls) // DB 更新被调用
  944. require.Equal(t, 1, invalidator.calls) // 缓存失效被调用
  945. require.Equal(t, 1, cache.releaseCalls) // 锁被释放
  946. }
  947. func TestPathA_GrokSuccessPersistenceFailureContainsProviderWithoutRetryOrMutation(t *testing.T) {
  948. account := &Account{
  949. ID: 110,
  950. Platform: PlatformGrok,
  951. Type: AccountTypeOAuth,
  952. Status: StatusActive,
  953. Credentials: map[string]any{
  954. "access_token": "attempted-access",
  955. "refresh_token": "attempted-refresh",
  956. },
  957. }
  958. repo := &tokenRefreshAccountRepo{
  959. conditionalSuccessErr: errors.New("database unavailable after provider success"),
  960. }
  961. repo.accountsByID = map[int64]*Account{account.ID: account}
  962. cfg := &config.Config{TokenRefresh: config.TokenRefreshConfig{
  963. MaxRetries: 3,
  964. RetryBackoffSeconds: 0,
  965. }}
  966. svc := NewTokenRefreshService(repo, nil, nil, nil, nil, nil, nil, cfg, nil)
  967. svc.SetRefreshAPI(NewOAuthRefreshAPI(repo, nil))
  968. refresher := &tokenRefresherStub{credentials: map[string]any{
  969. "access_token": "provider-access",
  970. "refresh_token": "provider-refresh",
  971. }}
  972. err := svc.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  973. var containmentErr *providerCycleContainmentRefreshError
  974. require.ErrorAs(t, err, &containmentErr)
  975. require.Equal(t, 1, refresher.calls, "a provider-issued rotated token must never be retried after persistence fails")
  976. require.Equal(t, 1, repo.conditionalSuccessCalls)
  977. require.Zero(t, repo.conditionalErrorCalls)
  978. require.Zero(t, repo.conditionalTempCalls)
  979. require.Equal(t, StatusActive, account.Status)
  980. require.Equal(t, "attempted-refresh", account.GetGrokRefreshToken())
  981. }
  982. func TestPathA_GrokSuccessPublishesDurableSchedulingState(t *testing.T) {
  983. account := &Account{
  984. ID: 111,
  985. Platform: PlatformGrok,
  986. Type: AccountTypeOAuth,
  987. Status: StatusActive,
  988. Schedulable: true,
  989. Credentials: map[string]any{
  990. "access_token": "attempted-access",
  991. "refresh_token": "attempted-refresh",
  992. },
  993. }
  994. repo := &tokenRefreshAccountRepo{
  995. snapshotReads: true,
  996. mutateSchedulingOnSuccessCAS: true,
  997. }
  998. repo.accountsByID = map[int64]*Account{account.ID: account}
  999. scheduler := &tokenRefreshSchedulerCache{}
  1000. cfg := &config.Config{TokenRefresh: config.TokenRefreshConfig{MaxRetries: 1}}
  1001. svc := NewTokenRefreshService(repo, nil, nil, nil, nil, nil, scheduler, cfg, nil)
  1002. svc.SetRefreshAPI(NewOAuthRefreshAPI(repo, nil))
  1003. refresher := &tokenRefresherStub{credentials: map[string]any{
  1004. "access_token": "provider-access",
  1005. "refresh_token": "provider-refresh",
  1006. }}
  1007. err := svc.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  1008. require.NoError(t, err)
  1009. require.Equal(t, StatusDisabled, repo.accountsByID[account.ID].Status)
  1010. require.False(t, repo.accountsByID[account.ID].Schedulable)
  1011. require.NotNil(t, repo.accountsByID[account.ID].RateLimitResetAt)
  1012. require.Equal(t, 1, scheduler.setAccountCalls)
  1013. require.NotNil(t, scheduler.lastAccount)
  1014. require.Equal(t, StatusDisabled, scheduler.lastAccount.Status)
  1015. require.False(t, scheduler.lastAccount.Schedulable)
  1016. require.NotNil(t, scheduler.lastAccount.RateLimitResetAt,
  1017. "post-refresh cache publication must preserve the durable concurrent exclusion state")
  1018. }
  1019. func TestPathA_GrokCancelAfterSuccessCASUsesDetachedDurableStateAndInvalidatesCache(t *testing.T) {
  1020. account := &Account{
  1021. ID: 112,
  1022. Platform: PlatformGrok,
  1023. Type: AccountTypeOAuth,
  1024. Status: StatusActive,
  1025. Schedulable: true,
  1026. Credentials: map[string]any{
  1027. "access_token": "attempted-access",
  1028. "refresh_token": "attempted-refresh",
  1029. },
  1030. }
  1031. ctx, cancel := context.WithCancel(context.Background())
  1032. repo := &tokenRefreshAccountRepo{
  1033. cancelOnUpdate: cancel,
  1034. snapshotReads: true,
  1035. respectReadContext: true,
  1036. mutateSchedulingOnSuccessCAS: true,
  1037. }
  1038. repo.accountsByID = map[int64]*Account{account.ID: account}
  1039. invalidator := &tokenCacheInvalidatorStub{}
  1040. scheduler := &tokenRefreshSchedulerCache{}
  1041. cache := &mockTokenCacheForRefreshAPI{lockResult: true}
  1042. cfg := &config.Config{TokenRefresh: config.TokenRefreshConfig{MaxRetries: 1}}
  1043. svc := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, scheduler, cfg, nil)
  1044. svc.SetRefreshAPI(NewOAuthRefreshAPI(repo, cache))
  1045. refresher := &tokenRefresherStub{credentials: map[string]any{
  1046. "access_token": "provider-access",
  1047. "refresh_token": "provider-refresh",
  1048. }}
  1049. err := svc.refreshWithRetry(ctx, account, refresher, refresher, time.Hour)
  1050. require.ErrorIs(t, err, context.Canceled)
  1051. require.Equal(t, 1, repo.conditionalSuccessCalls)
  1052. require.Equal(t, "provider-refresh", repo.accountsByID[account.ID].GetGrokRefreshToken())
  1053. require.Equal(t, 1, cache.deleteCalls)
  1054. require.NoError(t, cache.deleteCtxErr)
  1055. require.Equal(t, 1, invalidator.calls, "the pre-rotation access-token cache must be invalidated after committed CAS")
  1056. require.NoError(t, invalidator.ctxErr)
  1057. require.NotNil(t, invalidator.lastAccount)
  1058. require.Equal(t, "provider-refresh", invalidator.lastAccount.GetGrokRefreshToken())
  1059. require.Equal(t, StatusDisabled, invalidator.lastAccount.Status)
  1060. require.Equal(t, 1, scheduler.setAccountCalls)
  1061. require.NoError(t, scheduler.ctxErr)
  1062. require.NotNil(t, scheduler.lastAccount)
  1063. require.Equal(t, StatusDisabled, scheduler.lastAccount.Status)
  1064. require.False(t, scheduler.lastAccount.Schedulable)
  1065. require.NotNil(t, scheduler.lastAccount.RateLimitResetAt)
  1066. }
  1067. func TestTokenRefreshService_PersistedSuccessCrossingAttemptDeadlineStaysSuccessful(t *testing.T) {
  1068. account := &Account{
  1069. ID: 113,
  1070. Platform: PlatformGrok,
  1071. Type: AccountTypeOAuth,
  1072. Status: StatusActive,
  1073. Schedulable: true,
  1074. Credentials: map[string]any{
  1075. "access_token": "attempted-access",
  1076. "refresh_token": "attempted-refresh",
  1077. },
  1078. }
  1079. repo := &tokenRefreshAccountRepo{
  1080. snapshotReads: true,
  1081. durableReadDelay: 30 * time.Millisecond,
  1082. }
  1083. repo.accountsByID = map[int64]*Account{account.ID: account}
  1084. scheduler := &tokenRefreshSchedulerCache{}
  1085. svc := &TokenRefreshService{
  1086. accountRepo: repo,
  1087. refreshAPI: NewOAuthRefreshAPI(repo, nil),
  1088. refreshPolicy: DefaultBackgroundRefreshPolicy(),
  1089. cfg: &config.TokenRefreshConfig{MaxRetries: 1, ProviderFailureThreshold: 1},
  1090. schedulerCache: scheduler,
  1091. attemptTimeoutOverride: 10 * time.Millisecond,
  1092. }
  1093. refresher := &tokenRefresherStub{credentials: map[string]any{
  1094. "access_token": "provider-access",
  1095. "refresh_token": "provider-refresh",
  1096. }}
  1097. state := &tokenRefreshProviderState{
  1098. service: svc,
  1099. rateGate: newTokenRefreshRateGate(10000),
  1100. poolGate: newTokenRefreshConcurrencyGate(1),
  1101. }
  1102. err := svc.refreshWithRetryWithRateGate(context.Background(), account, refresher, refresher, time.Hour, state)
  1103. state.recordResult(err)
  1104. require.NoError(t, err)
  1105. require.Equal(t, 1, refresher.calls, "durably persisted success must not retry after only the internal attempt deadline elapsed")
  1106. require.Equal(t, 1, repo.conditionalSuccessCalls)
  1107. require.Zero(t, repo.conditionalTempCalls)
  1108. require.Zero(t, repo.setTempUnschedCalls)
  1109. require.False(t, state.isTripped(), "a durable success must not count toward the provider breaker")
  1110. require.Equal(t, "provider-refresh", repo.accountsByID[account.ID].GetGrokRefreshToken())
  1111. require.Equal(t, 1, scheduler.setAccountCalls)
  1112. }
  1113. func TestPathA_ParentCancellationAfterPersistStillSynchronizesCacheState(t *testing.T) {
  1114. account := &Account{
  1115. ID: 109,
  1116. Platform: PlatformGemini,
  1117. Type: AccountTypeOAuth,
  1118. Status: StatusActive,
  1119. }
  1120. ctx, cancel := context.WithCancel(context.Background())
  1121. repo := &tokenRefreshAccountRepo{cancelOnUpdate: cancel}
  1122. repo.accountsByID = map[int64]*Account{account.ID: account}
  1123. invalidator := &tokenCacheInvalidatorStub{}
  1124. scheduler := &tokenRefreshSchedulerCache{}
  1125. cache := &mockTokenCacheForRefreshAPI{lockResult: true}
  1126. service, refresher := buildPathAService(repo, cache, invalidator)
  1127. service.schedulerCache = scheduler
  1128. err := service.refreshWithRetry(ctx, account, refresher, refresher, time.Hour)
  1129. require.ErrorIs(t, err, context.Canceled)
  1130. require.Equal(t, 1, repo.updateCredentialsCalls, "credentials were durably persisted before cancellation")
  1131. require.Equal(t, 1, invalidator.calls)
  1132. require.NoError(t, invalidator.ctxErr, "post-persist invalidation must use bounded cleanup context")
  1133. require.Equal(t, 1, scheduler.setAccountCalls)
  1134. require.NoError(t, scheduler.ctxErr, "scheduler sync must use bounded cleanup context")
  1135. }
  1136. // TestPathA_LockHeld 锁被其他 worker 持有 → 返回 errRefreshSkipped
  1137. func TestPathA_LockHeld(t *testing.T) {
  1138. account := &Account{
  1139. ID: 101,
  1140. Platform: PlatformGemini,
  1141. Type: AccountTypeOAuth,
  1142. Status: StatusActive,
  1143. }
  1144. repo := &tokenRefreshAccountRepo{}
  1145. invalidator := &tokenCacheInvalidatorStub{}
  1146. cache := &mockTokenCacheForRefreshAPI{lockResult: false} // 锁获取失败(被占)
  1147. service, refresher := buildPathAService(repo, cache, invalidator)
  1148. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  1149. require.ErrorIs(t, err, errRefreshSkipped)
  1150. require.Equal(t, 0, repo.updateCalls) // 不应更新 DB
  1151. require.Equal(t, 0, invalidator.calls) // 不应触发缓存失效
  1152. }
  1153. // TestPathA_AlreadyRefreshed 二次检查发现已被其他路径刷新 → 返回 errRefreshSkipped
  1154. func TestPathA_AlreadyRefreshed(t *testing.T) {
  1155. // NeedsRefresh 返回 false → RefreshIfNeeded 返回 {Refreshed: false}
  1156. account := &Account{
  1157. ID: 102,
  1158. Platform: PlatformGemini,
  1159. Type: AccountTypeOAuth,
  1160. Status: StatusActive,
  1161. }
  1162. repo := &tokenRefreshAccountRepo{}
  1163. repo.accountsByID = map[int64]*Account{account.ID: account}
  1164. invalidator := &tokenCacheInvalidatorStub{}
  1165. cache := &mockTokenCacheForRefreshAPI{lockResult: true}
  1166. service, _ := buildPathAService(repo, cache, invalidator)
  1167. // 使用一个 NeedsRefresh 返回 false 的 stub
  1168. noRefreshNeeded := &tokenRefresherStub{
  1169. credentials: map[string]any{"access_token": "token"},
  1170. }
  1171. // 覆盖 NeedsRefresh 行为 — 我们需要一个新的 stub 类型
  1172. alwaysFreshStub := &alwaysFreshRefresherStub{}
  1173. err := service.refreshWithRetry(context.Background(), account, noRefreshNeeded, alwaysFreshStub, time.Hour)
  1174. require.ErrorIs(t, err, errRefreshSkipped)
  1175. require.Equal(t, 0, repo.updateCalls)
  1176. require.Equal(t, 0, invalidator.calls)
  1177. }
  1178. // alwaysFreshRefresherStub 二次检查时认为不需要刷新(模拟已被其他路径刷新)
  1179. type alwaysFreshRefresherStub struct{}
  1180. func (r *alwaysFreshRefresherStub) CanRefresh(_ *Account) bool { return true }
  1181. func (r *alwaysFreshRefresherStub) NeedsRefresh(_ *Account, _ time.Duration) bool { return false }
  1182. func (r *alwaysFreshRefresherStub) Refresh(_ context.Context, _ *Account) (map[string]any, error) {
  1183. return nil, errors.New("should not be called")
  1184. }
  1185. func (r *alwaysFreshRefresherStub) CacheKey(account *Account) string {
  1186. return "test:fresh:" + account.Platform
  1187. }
  1188. // TestPathA_NonRetryableError 统一 API 路径返回不可重试错误 → SetError
  1189. func TestPathA_NonRetryableError(t *testing.T) {
  1190. account := &Account{
  1191. ID: 103,
  1192. Platform: PlatformGemini,
  1193. Type: AccountTypeOAuth,
  1194. Status: StatusActive,
  1195. }
  1196. repo := &tokenRefreshAccountRepo{}
  1197. repo.accountsByID = map[int64]*Account{account.ID: account}
  1198. invalidator := &tokenCacheInvalidatorStub{}
  1199. cache := &mockTokenCacheForRefreshAPI{lockResult: true}
  1200. service, _ := buildPathAService(repo, cache, invalidator)
  1201. refresher := &tokenRefresherStub{
  1202. err: errors.New("invalid_grant: token revoked"),
  1203. }
  1204. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  1205. require.Error(t, err)
  1206. require.Equal(t, 1, repo.setErrorCalls) // 应标记 error 状态
  1207. require.Equal(t, 0, repo.updateCalls) // 不应更新 credentials
  1208. require.Equal(t, 1, invalidator.calls) // 永久凭证失败后必须失效旧 token 缓存
  1209. }
  1210. // TestPathA_RetryableErrorExhausted 统一 API 路径可重试错误耗尽 → 不标记 error
  1211. func TestPathA_RetryableErrorExhausted(t *testing.T) {
  1212. account := &Account{
  1213. ID: 104,
  1214. Platform: PlatformGemini,
  1215. Type: AccountTypeOAuth,
  1216. Status: StatusActive,
  1217. }
  1218. repo := &tokenRefreshAccountRepo{}
  1219. repo.accountsByID = map[int64]*Account{account.ID: account}
  1220. invalidator := &tokenCacheInvalidatorStub{}
  1221. cache := &mockTokenCacheForRefreshAPI{lockResult: true}
  1222. cfg := &config.Config{
  1223. TokenRefresh: config.TokenRefreshConfig{
  1224. MaxRetries: 2,
  1225. RetryBackoffSeconds: 0,
  1226. },
  1227. }
  1228. service := NewTokenRefreshService(repo, nil, nil, nil, nil, invalidator, nil, cfg, nil)
  1229. refreshAPI := NewOAuthRefreshAPI(repo, cache)
  1230. service.SetRefreshAPI(refreshAPI)
  1231. refresher := &tokenRefresherStub{
  1232. err: errors.New("network timeout"),
  1233. }
  1234. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  1235. require.Error(t, err)
  1236. require.Equal(t, 0, repo.setErrorCalls) // 可重试错误不标记 error
  1237. require.Equal(t, 0, repo.updateCalls) // 刷新失败不应更新
  1238. require.Equal(t, 0, invalidator.calls) // 不应触发缓存失效
  1239. }
  1240. func TestPathA_GrokPermanentFailureCASLetsConcurrentAccountRepairWin(t *testing.T) {
  1241. tests := []struct {
  1242. name string
  1243. configure func(*tokenRefreshAccountRepo)
  1244. assert func(*testing.T, *Account)
  1245. }{
  1246. {
  1247. name: "credential reauthorization",
  1248. configure: func(repo *tokenRefreshAccountRepo) {
  1249. repo.reauthorizeOnErrorCAS = true
  1250. },
  1251. assert: func(t *testing.T, account *Account) {
  1252. require.Equal(t, "fresh-refresh", account.GetGrokRefreshToken())
  1253. },
  1254. },
  1255. {
  1256. name: "proxy repair",
  1257. configure: func(repo *tokenRefreshAccountRepo) {
  1258. repo.repairProxyOnErrorCAS = true
  1259. },
  1260. assert: func(t *testing.T, account *Account) {
  1261. require.NotNil(t, account.ProxyID)
  1262. require.Equal(t, int64(902), *account.ProxyID)
  1263. require.Equal(t, "attempted-refresh", account.GetGrokRefreshToken(),
  1264. "proxy-only repair must prove the proxy fingerprint independently of credentials")
  1265. },
  1266. },
  1267. }
  1268. for _, tt := range tests {
  1269. t.Run(tt.name, func(t *testing.T) {
  1270. proxyID := int64(901)
  1271. account := &Account{
  1272. ID: 120,
  1273. Platform: PlatformGrok,
  1274. Type: AccountTypeOAuth,
  1275. Status: StatusActive,
  1276. Schedulable: true,
  1277. ProxyID: &proxyID,
  1278. Credentials: map[string]any{
  1279. "access_token": "attempted-access",
  1280. "refresh_token": "attempted-refresh",
  1281. "_token_version": int64(1),
  1282. },
  1283. }
  1284. repo := &tokenRefreshAccountRepo{}
  1285. repo.accountsByID = map[int64]*Account{account.ID: account}
  1286. tt.configure(repo)
  1287. invalidator := &tokenCacheInvalidatorStub{}
  1288. cache := &mockTokenCacheForRefreshAPI{lockResult: true}
  1289. service, _ := buildPathAService(repo, cache, invalidator)
  1290. blocker := &tokenRefreshRuntimeBlocker{}
  1291. service.SetAccountRuntimeBlocker(blocker)
  1292. refresher := &tokenRefresherStub{err: errors.New("invalid_grant: revoked")}
  1293. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  1294. require.ErrorIs(t, err, errRefreshSkipped)
  1295. require.Equal(t, 1, repo.conditionalErrorCalls)
  1296. require.Zero(t, repo.setErrorCalls)
  1297. require.Zero(t, blocker.blockCalls)
  1298. require.Zero(t, invalidator.calls, "a stale permanent failure must not invalidate newly repaired credentials")
  1299. require.Equal(t, StatusActive, account.Status)
  1300. require.True(t, account.Schedulable)
  1301. tt.assert(t, account)
  1302. })
  1303. }
  1304. }
  1305. func TestPathA_GrokTransientFailureCASLetsConcurrentAccountRepairWin(t *testing.T) {
  1306. tests := []struct {
  1307. name string
  1308. configure func(*tokenRefreshAccountRepo)
  1309. assert func(*testing.T, *Account)
  1310. }{
  1311. {
  1312. name: "credential reauthorization",
  1313. configure: func(repo *tokenRefreshAccountRepo) {
  1314. repo.reauthorizeOnTempCAS = true
  1315. },
  1316. assert: func(t *testing.T, account *Account) {
  1317. require.Equal(t, "fresh-refresh", account.GetGrokRefreshToken())
  1318. },
  1319. },
  1320. {
  1321. name: "proxy repair",
  1322. configure: func(repo *tokenRefreshAccountRepo) {
  1323. repo.repairProxyOnTempCAS = true
  1324. },
  1325. assert: func(t *testing.T, account *Account) {
  1326. require.NotNil(t, account.ProxyID)
  1327. require.Equal(t, int64(902), *account.ProxyID)
  1328. require.Equal(t, "attempted-refresh", account.GetGrokRefreshToken(),
  1329. "proxy-only repair must prove the proxy fingerprint independently of credentials")
  1330. },
  1331. },
  1332. }
  1333. for _, tt := range tests {
  1334. t.Run(tt.name, func(t *testing.T) {
  1335. proxyID := int64(901)
  1336. account := &Account{
  1337. ID: 121,
  1338. Platform: PlatformGrok,
  1339. Type: AccountTypeOAuth,
  1340. Status: StatusActive,
  1341. Schedulable: true,
  1342. ProxyID: &proxyID,
  1343. Credentials: map[string]any{
  1344. "access_token": "attempted-access",
  1345. "refresh_token": "attempted-refresh",
  1346. "_token_version": int64(1),
  1347. },
  1348. }
  1349. repo := &tokenRefreshAccountRepo{}
  1350. repo.accountsByID = map[int64]*Account{account.ID: account}
  1351. tt.configure(repo)
  1352. invalidator := &tokenCacheInvalidatorStub{}
  1353. cache := &mockTokenCacheForRefreshAPI{lockResult: true}
  1354. service, _ := buildPathAService(repo, cache, invalidator)
  1355. blocker := &tokenRefreshRuntimeBlocker{}
  1356. service.SetAccountRuntimeBlocker(blocker)
  1357. refresher := &tokenRefresherStub{err: errors.New("temporary provider timeout")}
  1358. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  1359. require.ErrorIs(t, err, errRefreshSkipped)
  1360. require.Equal(t, 1, repo.conditionalTempCalls)
  1361. require.Zero(t, repo.setTempUnschedCalls)
  1362. require.Zero(t, blocker.blockCalls)
  1363. require.Equal(t, StatusActive, account.Status)
  1364. require.True(t, account.Schedulable)
  1365. require.Nil(t, account.TempUnschedulableUntil)
  1366. tt.assert(t, account)
  1367. })
  1368. }
  1369. }
  1370. func TestTokenRefreshService_GrokMissingConditionalMutationContractContainsProviderCycle(t *testing.T) {
  1371. tests := []struct {
  1372. name string
  1373. refreshErr error
  1374. }{
  1375. {name: "permanent failure", refreshErr: errors.New("invalid_grant: revoked")},
  1376. {name: "transient failure", refreshErr: errors.New("temporary provider timeout")},
  1377. }
  1378. for _, tt := range tests {
  1379. t.Run(tt.name, func(t *testing.T) {
  1380. svc := &TokenRefreshService{
  1381. accountRepo: &mockAccountRepoForGemini{},
  1382. refreshPolicy: DefaultBackgroundRefreshPolicy(),
  1383. cfg: &config.TokenRefreshConfig{MaxRetries: 1},
  1384. }
  1385. account := &Account{
  1386. ID: 122,
  1387. Platform: PlatformGrok,
  1388. Type: AccountTypeOAuth,
  1389. Status: StatusActive,
  1390. Schedulable: true,
  1391. Credentials: map[string]any{"refresh_token": "attempted"},
  1392. }
  1393. refresher := &tokenRefresherStub{err: tt.refreshErr}
  1394. err := svc.refreshWithRetry(context.Background(), account, refresher, nil, time.Hour)
  1395. var providerErr *providerConfigurationRefreshError
  1396. require.ErrorAs(t, err, &providerErr)
  1397. state := &tokenRefreshProviderState{service: svc}
  1398. state.recordResult(err)
  1399. require.True(t, state.isTripped(), "a missing safety contract must stop the provider cycle")
  1400. require.Equal(t, StatusActive, account.Status)
  1401. require.True(t, account.Schedulable)
  1402. })
  1403. }
  1404. }
  1405. func TestTokenRefreshService_GrokConditionalMutationErrorsContainProviderCycle(t *testing.T) {
  1406. tests := []struct {
  1407. name string
  1408. upstreamErr error
  1409. configureRepo func(*tokenRefreshAccountRepo, error)
  1410. expectedCASCalls func(*tokenRefreshAccountRepo) int
  1411. }{
  1412. {
  1413. name: "permanent failure",
  1414. upstreamErr: errors.New("invalid_grant: revoked"),
  1415. configureRepo: func(repo *tokenRefreshAccountRepo, casErr error) {
  1416. repo.conditionalErrorErr = casErr
  1417. },
  1418. expectedCASCalls: func(repo *tokenRefreshAccountRepo) int { return repo.conditionalErrorCalls },
  1419. },
  1420. {
  1421. name: "transient failure",
  1422. upstreamErr: errors.New("temporary provider timeout"),
  1423. configureRepo: func(repo *tokenRefreshAccountRepo, casErr error) {
  1424. repo.conditionalTempErr = casErr
  1425. },
  1426. expectedCASCalls: func(repo *tokenRefreshAccountRepo) int { return repo.conditionalTempCalls },
  1427. },
  1428. }
  1429. for _, tt := range tests {
  1430. t.Run(tt.name, func(t *testing.T) {
  1431. account := &Account{
  1432. ID: 123,
  1433. Platform: PlatformGrok,
  1434. Type: AccountTypeOAuth,
  1435. Status: StatusActive,
  1436. Schedulable: true,
  1437. Credentials: map[string]any{"refresh_token": "attempted"},
  1438. }
  1439. casErr := errors.New("conditional account mutation unavailable")
  1440. repo := &tokenRefreshAccountRepo{}
  1441. repo.accountsByID = map[int64]*Account{account.ID: account}
  1442. tt.configureRepo(repo, casErr)
  1443. invalidator := &tokenCacheInvalidatorStub{}
  1444. blocker := &tokenRefreshRuntimeBlocker{}
  1445. svc := &TokenRefreshService{
  1446. accountRepo: repo,
  1447. refreshPolicy: DefaultBackgroundRefreshPolicy(),
  1448. cfg: &config.TokenRefreshConfig{MaxRetries: 1},
  1449. cacheInvalidator: invalidator,
  1450. }
  1451. svc.SetAccountRuntimeBlocker(blocker)
  1452. refresher := &tokenRefresherStub{err: tt.upstreamErr}
  1453. err := svc.refreshWithRetry(context.Background(), account, refresher, nil, time.Hour)
  1454. var containmentErr *providerCycleContainmentRefreshError
  1455. require.ErrorAs(t, err, &containmentErr)
  1456. require.ErrorIs(t, err, casErr)
  1457. require.NotErrorIs(t, err, tt.upstreamErr, "a CAS execution failure must replace the stale upstream classification")
  1458. var permanentErr *accountPermanentRefreshError
  1459. require.False(t, errors.As(err, &permanentErr))
  1460. require.Equal(t, 1, tt.expectedCASCalls(repo))
  1461. state := &tokenRefreshProviderState{service: svc}
  1462. state.recordResult(err)
  1463. require.True(t, state.isTripped(), "an unsafe mutation result must stop the provider cycle immediately")
  1464. require.Zero(t, repo.setErrorCalls)
  1465. require.Zero(t, repo.setTempUnschedCalls)
  1466. require.Zero(t, blocker.blockCalls)
  1467. require.Zero(t, invalidator.calls)
  1468. require.Equal(t, StatusActive, account.Status)
  1469. require.True(t, account.Schedulable)
  1470. })
  1471. }
  1472. }
  1473. // TestPathA_DBUpdateFailed 统一 API 路径 DB 更新失败 → 返回 error,不执行 postRefreshActions
  1474. func TestPathA_DBUpdateFailed(t *testing.T) {
  1475. account := &Account{
  1476. ID: 105,
  1477. Platform: PlatformGemini,
  1478. Type: AccountTypeOAuth,
  1479. Status: StatusActive,
  1480. }
  1481. repo := &tokenRefreshAccountRepo{updateErr: errors.New("db connection lost")}
  1482. repo.accountsByID = map[int64]*Account{account.ID: account}
  1483. invalidator := &tokenCacheInvalidatorStub{}
  1484. cache := &mockTokenCacheForRefreshAPI{lockResult: true}
  1485. service, refresher := buildPathAService(repo, cache, invalidator)
  1486. err := service.refreshWithRetry(context.Background(), account, refresher, refresher, time.Hour)
  1487. require.Error(t, err)
  1488. require.ErrorIs(t, err, errOAuthRefreshCredentialPersist)
  1489. require.Equal(t, 1, repo.updateCalls) // DB 更新被尝试
  1490. require.Equal(t, 0, invalidator.calls) // DB 失败时不应触发缓存失效
  1491. }