grok_oauth_reconciliation_test.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "reflect"
  7. "sort"
  8. "strings"
  9. "sync"
  10. "testing"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/stretchr/testify/require"
  14. )
  15. type grokReconcileRepo struct {
  16. AccountRepository
  17. mu sync.Mutex
  18. accounts []Account
  19. requests []OAuthRefreshPageOptions
  20. setErrorIDs []int64
  21. updatedCredIDs []int64
  22. setErrorMessage []string
  23. getByIDOverrides map[int64]Account
  24. pageOverride *OAuthRefreshCandidatePage
  25. reauthorizeOnCAS bool
  26. reauthorizeOnRefreshCAS bool
  27. conditionalCalls int
  28. }
  29. func (r *grokReconcileRepo) GetByID(_ context.Context, id int64) (*Account, error) {
  30. r.mu.Lock()
  31. defer r.mu.Unlock()
  32. if override, ok := r.getByIDOverrides[id]; ok {
  33. account := override
  34. return &account, nil
  35. }
  36. for i := range r.accounts {
  37. if r.accounts[i].ID == id {
  38. account := r.accounts[i]
  39. return &account, nil
  40. }
  41. }
  42. return nil, ErrAccountNotFound
  43. }
  44. func (r *grokReconcileRepo) ListOAuthRefreshCandidatePage(_ context.Context, options OAuthRefreshPageOptions) (*OAuthRefreshCandidatePage, error) {
  45. r.mu.Lock()
  46. defer r.mu.Unlock()
  47. r.requests = append(r.requests, options)
  48. if r.pageOverride != nil {
  49. page := *r.pageOverride
  50. page.Accounts = append([]Account(nil), r.pageOverride.Accounts...)
  51. return &page, nil
  52. }
  53. accounts := append([]Account(nil), r.accounts...)
  54. sort.Slice(accounts, func(i, j int) bool { return accounts[i].ID < accounts[j].ID })
  55. page := make([]Account, 0, options.Limit)
  56. for _, account := range accounts {
  57. if account.ID <= options.AfterID {
  58. continue
  59. }
  60. platformAllowed := false
  61. for _, platform := range options.Platforms {
  62. if account.Platform == platform {
  63. platformAllowed = true
  64. break
  65. }
  66. }
  67. if !platformAllowed || options.ActiveOnly && account.Status != StatusActive {
  68. continue
  69. }
  70. if options.IncludeSetupToken {
  71. if account.Type != AccountTypeOAuth && account.Type != AccountTypeSetupToken {
  72. continue
  73. }
  74. } else if account.Type != AccountTypeOAuth {
  75. continue
  76. }
  77. if options.RequireRefreshToken && strings.TrimSpace(account.GetGrokRefreshToken()) == "" {
  78. continue
  79. }
  80. page = append(page, account)
  81. if len(page) == options.Limit {
  82. break
  83. }
  84. }
  85. result := &OAuthRefreshCandidatePage{Accounts: page, HasMore: len(page) == options.Limit}
  86. if len(page) > 0 {
  87. result.NextAfterID = page[len(page)-1].ID
  88. }
  89. return result, nil
  90. }
  91. func (r *grokReconcileRepo) UpdateCredentials(_ context.Context, id int64, credentials map[string]any) error {
  92. r.mu.Lock()
  93. defer r.mu.Unlock()
  94. r.updatedCredIDs = append(r.updatedCredIDs, id)
  95. for i := range r.accounts {
  96. if r.accounts[i].ID == id {
  97. r.accounts[i].Credentials = MergeCredentials(r.accounts[i].Credentials, credentials)
  98. }
  99. }
  100. return nil
  101. }
  102. func (r *grokReconcileRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
  103. r.mu.Lock()
  104. defer r.mu.Unlock()
  105. for i := range r.accounts {
  106. if r.accounts[i].ID != id {
  107. continue
  108. }
  109. if r.accounts[i].Extra == nil {
  110. r.accounts[i].Extra = make(map[string]any)
  111. }
  112. for key, value := range updates {
  113. r.accounts[i].Extra[key] = value
  114. }
  115. break
  116. }
  117. return nil
  118. }
  119. func (r *grokReconcileRepo) SetError(_ context.Context, id int64, message string) error {
  120. r.mu.Lock()
  121. defer r.mu.Unlock()
  122. r.setErrorIDs = append(r.setErrorIDs, id)
  123. r.setErrorMessage = append(r.setErrorMessage, message)
  124. for i := range r.accounts {
  125. if r.accounts[i].ID == id {
  126. r.accounts[i].Status = StatusError
  127. r.accounts[i].Schedulable = false
  128. r.accounts[i].ErrorMessage = message
  129. }
  130. }
  131. return nil
  132. }
  133. func (r *grokReconcileRepo) SetGrokOAuthErrorIfCredentialsUnchanged(
  134. _ context.Context,
  135. id int64,
  136. expectedCredentials map[string]any,
  137. message string,
  138. ) (bool, error) {
  139. r.mu.Lock()
  140. defer r.mu.Unlock()
  141. r.conditionalCalls++
  142. for i := range r.accounts {
  143. account := &r.accounts[i]
  144. if account.ID != id {
  145. continue
  146. }
  147. if r.reauthorizeOnCAS {
  148. r.reauthorizeOnCAS = false
  149. account.Credentials = map[string]any{
  150. "access_token": "fresh-access",
  151. "refresh_token": "fresh-refresh",
  152. "expires_at": time.Now().UTC().Add(4 * time.Hour).Format(time.RFC3339),
  153. "_token_version": int64(2),
  154. }
  155. }
  156. if account.Platform != PlatformGrok || account.Type != AccountTypeOAuth || account.Status != StatusActive ||
  157. strings.TrimSpace(account.GetGrokRefreshToken()) != "" || !reflect.DeepEqual(account.Credentials, expectedCredentials) {
  158. return false, nil
  159. }
  160. r.setErrorIDs = append(r.setErrorIDs, id)
  161. r.setErrorMessage = append(r.setErrorMessage, message)
  162. account.Status = StatusError
  163. account.Schedulable = false
  164. account.ErrorMessage = message
  165. return true, nil
  166. }
  167. return false, nil
  168. }
  169. func (r *grokReconcileRepo) SetGrokOAuthRefreshErrorIfCredentialsUnchanged(
  170. _ context.Context,
  171. id int64,
  172. expectedCredentials map[string]any,
  173. expectedProxyID *int64,
  174. message string,
  175. ) (bool, error) {
  176. r.mu.Lock()
  177. defer r.mu.Unlock()
  178. for i := range r.accounts {
  179. account := &r.accounts[i]
  180. if account.ID != id {
  181. continue
  182. }
  183. if r.reauthorizeOnRefreshCAS {
  184. r.reauthorizeOnRefreshCAS = false
  185. account.Credentials = map[string]any{
  186. "access_token": "fresh-access",
  187. "refresh_token": "fresh-refresh",
  188. "expires_at": time.Now().UTC().Add(4 * time.Hour).Format(time.RFC3339),
  189. "_token_version": int64(3),
  190. }
  191. }
  192. if account.Platform != PlatformGrok || account.Type != AccountTypeOAuth || account.Status != StatusActive ||
  193. !reflect.DeepEqual(account.ProxyID, expectedProxyID) ||
  194. !reflect.DeepEqual(account.Credentials, expectedCredentials) {
  195. return false, nil
  196. }
  197. r.setErrorIDs = append(r.setErrorIDs, id)
  198. r.setErrorMessage = append(r.setErrorMessage, message)
  199. account.Status = StatusError
  200. account.Schedulable = false
  201. account.ErrorMessage = message
  202. return true, nil
  203. }
  204. return false, nil
  205. }
  206. func (r *grokReconcileRepo) SetGrokOAuthRefreshTempUnschedulableIfCredentialsUnchanged(
  207. _ context.Context,
  208. id int64,
  209. expectedCredentials map[string]any,
  210. expectedProxyID *int64,
  211. until time.Time,
  212. reason string,
  213. ) (bool, error) {
  214. r.mu.Lock()
  215. defer r.mu.Unlock()
  216. for i := range r.accounts {
  217. account := &r.accounts[i]
  218. if account.ID != id {
  219. continue
  220. }
  221. if account.Platform != PlatformGrok || account.Type != AccountTypeOAuth || account.Status != StatusActive ||
  222. !reflect.DeepEqual(account.ProxyID, expectedProxyID) ||
  223. !reflect.DeepEqual(account.Credentials, expectedCredentials) {
  224. return false, nil
  225. }
  226. account.TempUnschedulableUntil = &until
  227. account.TempUnschedulableReason = reason
  228. return true, nil
  229. }
  230. return false, nil
  231. }
  232. func (r *grokReconcileRepo) snapshot() ([]OAuthRefreshPageOptions, []int64, []int64, []string) {
  233. r.mu.Lock()
  234. defer r.mu.Unlock()
  235. return append([]OAuthRefreshPageOptions(nil), r.requests...), append([]int64(nil), r.setErrorIDs...), append([]int64(nil), r.updatedCredIDs...), append([]string(nil), r.setErrorMessage...)
  236. }
  237. type reconcileInvalidator struct {
  238. mu sync.Mutex
  239. ids []int64
  240. err error
  241. }
  242. type reconcileRuntimeBlocker struct {
  243. mu sync.Mutex
  244. blocked []int64
  245. cleared []int64
  246. }
  247. func (b *reconcileRuntimeBlocker) BlockAccountScheduling(account *Account, _ time.Time, _ string) {
  248. b.mu.Lock()
  249. defer b.mu.Unlock()
  250. if account != nil {
  251. b.blocked = append(b.blocked, account.ID)
  252. }
  253. }
  254. func (b *reconcileRuntimeBlocker) ClearAccountSchedulingBlock(accountID int64) {
  255. b.mu.Lock()
  256. defer b.mu.Unlock()
  257. b.cleared = append(b.cleared, accountID)
  258. }
  259. func (b *reconcileRuntimeBlocker) snapshot() (blocked, cleared []int64) {
  260. b.mu.Lock()
  261. defer b.mu.Unlock()
  262. return append([]int64(nil), b.blocked...), append([]int64(nil), b.cleared...)
  263. }
  264. func (i *reconcileInvalidator) InvalidateToken(_ context.Context, account *Account) error {
  265. i.mu.Lock()
  266. defer i.mu.Unlock()
  267. i.ids = append(i.ids, account.ID)
  268. return i.err
  269. }
  270. func (i *reconcileInvalidator) count() int {
  271. i.mu.Lock()
  272. defer i.mu.Unlock()
  273. return len(i.ids)
  274. }
  275. func newGrokReconcileService(repo *grokReconcileRepo, refresher *poolHealthRefresher, invalidator TokenCacheInvalidator) *TokenRefreshService {
  276. return &TokenRefreshService{
  277. accountRepo: repo,
  278. candidatePager: repo,
  279. cacheInvalidator: invalidator,
  280. registrations: []tokenRefreshRegistration{{
  281. platform: PlatformGrok,
  282. refresher: refresher,
  283. executor: refresher,
  284. }},
  285. refreshPolicy: DefaultBackgroundRefreshPolicy(),
  286. cfg: &config.TokenRefreshConfig{
  287. MaxRetries: 1,
  288. CandidatePageSize: 50,
  289. ProviderConcurrency: 2,
  290. ProviderQPS: 100,
  291. ProviderFailureThreshold: 3,
  292. AttemptTimeoutSeconds: 1,
  293. },
  294. }
  295. }
  296. func grokReconcileFixtures() []Account {
  297. now := time.Now().UTC()
  298. return []Account{
  299. {
  300. ID: 1,
  301. Platform: PlatformGrok,
  302. Type: AccountTypeOAuth,
  303. Status: StatusActive,
  304. Schedulable: true,
  305. Credentials: map[string]any{"access_token": "access-secret"},
  306. },
  307. {
  308. ID: 2,
  309. Platform: PlatformGrok,
  310. Type: AccountTypeOAuth,
  311. Status: StatusActive,
  312. Schedulable: true,
  313. Credentials: map[string]any{"refresh_token": "refresh-secret", "expires_at": now.Add(10 * time.Minute).Format(time.RFC3339)},
  314. },
  315. {
  316. ID: 3,
  317. Platform: PlatformGrok,
  318. Type: AccountTypeOAuth,
  319. Status: StatusActive,
  320. Schedulable: true,
  321. Credentials: map[string]any{"access_token": "access-secret", "refresh_token": "refresh-secret", "expires_at": now.Add(30 * time.Minute).Format(time.RFC3339)},
  322. },
  323. {
  324. ID: 4,
  325. Platform: PlatformGrok,
  326. Type: AccountTypeOAuth,
  327. Status: StatusActive,
  328. Schedulable: true,
  329. Credentials: map[string]any{"access_token": "access-secret", "refresh_token": "refresh-secret", "expires_at": now.Add(4 * time.Hour).Format(time.RFC3339)},
  330. },
  331. {
  332. ID: 5,
  333. Platform: PlatformGrok,
  334. Type: AccountTypeAPIKey,
  335. Status: StatusActive,
  336. Schedulable: true,
  337. Credentials: map[string]any{"api_key": "api-key-secret"},
  338. },
  339. }
  340. }
  341. func TestTokenRefreshService_ReconcileGrokOAuthDefaultsToDryRunAndSanitizedPlan(t *testing.T) {
  342. repo := &grokReconcileRepo{accounts: grokReconcileFixtures()}
  343. refresher := &poolHealthRefresher{}
  344. svc := newGrokReconcileService(repo, refresher, nil)
  345. result, err := svc.ReconcileGrokOAuth(context.Background(), GrokOAuthReconcileInput{})
  346. require.NoError(t, err)
  347. require.True(t, result.DryRun)
  348. require.Equal(t, 4, result.Scanned, "Grok API-key rows must not enter the OAuth reconciliation page")
  349. require.Equal(t, 3, result.Actionable)
  350. require.Equal(t, 1, result.WouldBlock)
  351. require.Equal(t, 2, result.WouldRefresh)
  352. require.Zero(t, result.Blocked)
  353. require.Zero(t, result.Refreshed)
  354. require.Zero(t, refresher.calls.Load())
  355. _, setErrorIDs, updatedIDs, _ := repo.snapshot()
  356. require.Empty(t, setErrorIDs)
  357. require.Empty(t, updatedIDs)
  358. payload, err := json.Marshal(result)
  359. require.NoError(t, err)
  360. text := string(payload)
  361. require.NotContains(t, text, "access-secret")
  362. require.NotContains(t, text, "refresh-secret")
  363. require.NotContains(t, text, "api-key-secret")
  364. require.NotContains(t, text, `"credentials":`)
  365. }
  366. func TestGrokTokenRefresher_NeedsRefreshWhenAccessTokenMissingDespiteFarFutureExpiry(t *testing.T) {
  367. refresher := NewGrokTokenRefresher(nil)
  368. account := grokPoolAccount(99)
  369. delete(account.Credentials, "access_token")
  370. account.Credentials["expires_at"] = time.Now().UTC().Add(12 * time.Hour).Format(time.RFC3339)
  371. require.True(t, refresher.NeedsRefresh(&account, time.Hour))
  372. }
  373. func TestTokenRefreshService_ReconcileGrokOAuthApplyIsIdempotent(t *testing.T) {
  374. repo := &grokReconcileRepo{accounts: grokReconcileFixtures()}
  375. invalidator := &reconcileInvalidator{}
  376. refresher := &poolHealthRefresher{newCredentials: map[string]any{
  377. "access_token": "rotated-access-secret",
  378. "refresh_token": "rotated-refresh-secret",
  379. "expires_at": time.Now().UTC().Add(4 * time.Hour).Format(time.RFC3339),
  380. }}
  381. svc := newGrokReconcileService(repo, refresher, invalidator)
  382. first, err := svc.ReconcileGrokOAuth(context.Background(), GrokOAuthReconcileInput{Apply: true, Limit: 50})
  383. require.NoError(t, err)
  384. require.False(t, first.DryRun)
  385. require.Equal(t, 1, first.Blocked)
  386. require.Equal(t, 2, first.Refreshed)
  387. require.Zero(t, first.Failed)
  388. requests, setErrorIDs, updatedIDs, messages := repo.snapshot()
  389. require.Equal(t, []int64{1}, setErrorIDs)
  390. sort.Slice(updatedIDs, func(i, j int) bool { return updatedIDs[i] < updatedIDs[j] })
  391. require.Equal(t, []int64{2, 3}, updatedIDs)
  392. require.Len(t, messages, 1)
  393. require.NotContains(t, messages[0], "secret")
  394. require.False(t, requests[0].RequireRefreshToken, "structurally invalid rows must remain discoverable")
  395. require.False(t, requests[0].IncludeSetupToken)
  396. require.Equal(t, 3, invalidator.count(), "block and refresh actions must invalidate token cache state")
  397. second, err := svc.ReconcileGrokOAuth(context.Background(), GrokOAuthReconcileInput{Apply: true, Limit: 50})
  398. require.NoError(t, err)
  399. require.Zero(t, second.Actionable)
  400. require.Equal(t, int64(2), refresher.calls.Load(), "already refreshed rows must not be refreshed again")
  401. _, setErrorIDs, updatedIDs, _ = repo.snapshot()
  402. require.Equal(t, []int64{1}, setErrorIDs, "already blocked invalid rows must not transition twice")
  403. require.Len(t, updatedIDs, 2)
  404. }
  405. func TestTokenRefreshService_ReconcileGrokOAuthCursorResumesWithoutDuplicates(t *testing.T) {
  406. fixtures := grokReconcileFixtures()[:3]
  407. repo := &grokReconcileRepo{accounts: fixtures}
  408. svc := newGrokReconcileService(repo, &poolHealthRefresher{}, nil)
  409. first, err := svc.ReconcileGrokOAuth(context.Background(), GrokOAuthReconcileInput{Limit: 2})
  410. require.NoError(t, err)
  411. require.True(t, first.HasMore)
  412. require.Equal(t, int64(2), first.NextAfterID)
  413. require.Len(t, first.Items, 2)
  414. second, err := svc.ReconcileGrokOAuth(context.Background(), GrokOAuthReconcileInput{AfterID: first.NextAfterID, Limit: 2})
  415. require.NoError(t, err)
  416. require.False(t, second.HasMore)
  417. require.Zero(t, second.NextAfterID)
  418. require.Len(t, second.Items, 1)
  419. require.NotEqual(t, first.Items[0].AccountID, second.Items[0].AccountID)
  420. require.NotEqual(t, first.Items[1].AccountID, second.Items[0].AccountID)
  421. }
  422. func TestTokenRefreshService_ReconcileGrokOAuthCursorUsesRawPageAfterHydrationGap(t *testing.T) {
  423. account := grokReconcileFixtures()[0]
  424. repo := &grokReconcileRepo{pageOverride: &OAuthRefreshCandidatePage{
  425. Accounts: []Account{account},
  426. NextAfterID: account.ID + 1,
  427. HasMore: true,
  428. }}
  429. svc := newGrokReconcileService(repo, &poolHealthRefresher{}, nil)
  430. result, err := svc.ReconcileGrokOAuth(context.Background(), GrokOAuthReconcileInput{Limit: 2})
  431. require.NoError(t, err)
  432. require.True(t, result.HasMore)
  433. require.Equal(t, account.ID+1, result.NextAfterID,
  434. "cursor must advance past a raw selected ID that disappeared during hydration")
  435. }
  436. func TestTokenRefreshService_ReconcileGrokOAuthRejectsConflictingApplyMode(t *testing.T) {
  437. svc := newGrokReconcileService(&grokReconcileRepo{}, &poolHealthRefresher{}, nil)
  438. _, err := svc.ReconcileGrokOAuth(context.Background(), GrokOAuthReconcileInput{Apply: true, DryRun: true})
  439. require.ErrorIs(t, err, ErrGrokOAuthReconcileMode)
  440. }
  441. func TestTokenRefreshService_ReconcileGrokOAuthSkipsStaleBlockAfterConcurrentReauthorization(t *testing.T) {
  442. stale := grokReconcileFixtures()[0]
  443. latest := stale
  444. latest.Credentials = map[string]any{
  445. "access_token": "fresh-access",
  446. "refresh_token": "fresh-refresh",
  447. "expires_at": time.Now().UTC().Add(4 * time.Hour).Format(time.RFC3339),
  448. }
  449. repo := &grokReconcileRepo{
  450. accounts: []Account{stale},
  451. getByIDOverrides: map[int64]Account{stale.ID: latest},
  452. }
  453. svc := newGrokReconcileService(repo, &poolHealthRefresher{}, &reconcileInvalidator{})
  454. result, err := svc.ReconcileGrokOAuth(context.Background(), GrokOAuthReconcileInput{Apply: true, Limit: 50})
  455. require.NoError(t, err)
  456. require.Zero(t, result.Blocked)
  457. require.Equal(t, 1, result.Skipped)
  458. require.Equal(t, GrokOAuthReconcileOutcomeSkipped, result.Items[0].Outcome)
  459. _, setErrorIDs, _, _ := repo.snapshot()
  460. require.Empty(t, setErrorIDs, "a concurrently reauthorized account must not be disabled from stale page state")
  461. }
  462. func TestTokenRefreshService_ReconcileGrokOAuthDoesNotRuntimeBlockWhenReauthorizationWinsConditionalMutation(t *testing.T) {
  463. account := grokReconcileFixtures()[0]
  464. account.Credentials["_token_version"] = int64(1)
  465. repo := &grokReconcileRepo{
  466. accounts: []Account{account},
  467. reauthorizeOnCAS: true,
  468. }
  469. invalidator := &reconcileInvalidator{}
  470. blocker := &reconcileRuntimeBlocker{}
  471. svc := newGrokReconcileService(repo, &poolHealthRefresher{}, invalidator)
  472. svc.SetAccountRuntimeBlocker(blocker)
  473. result, err := svc.ReconcileGrokOAuth(context.Background(), GrokOAuthReconcileInput{Apply: true, Limit: 50})
  474. require.NoError(t, err)
  475. require.Zero(t, result.Blocked)
  476. require.Equal(t, 1, result.Skipped)
  477. require.Equal(t, GrokOAuthReconcileOutcomeSkipped, result.Items[0].Outcome)
  478. require.Zero(t, invalidator.count(), "a lost compare-and-set race must not invalidate fresh credentials")
  479. _, setErrorIDs, _, _ := repo.snapshot()
  480. require.Empty(t, setErrorIDs)
  481. require.Equal(t, 1, repo.conditionalCalls)
  482. blocked, cleared := blocker.snapshot()
  483. require.Empty(t, blocked, "a lost compare-and-set race must never install a runtime block")
  484. require.Empty(t, cleared, "reconciliation must not clear a block it does not own")
  485. latest, getErr := repo.GetByID(context.Background(), account.ID)
  486. require.NoError(t, getErr)
  487. require.Equal(t, StatusActive, latest.Status)
  488. require.True(t, latest.Schedulable)
  489. require.Equal(t, "fresh-refresh", latest.GetGrokRefreshToken())
  490. }
  491. func TestTokenRefreshService_ReconcileGrokOAuthReportsPermanentRefreshMutationAsBlocked(t *testing.T) {
  492. account := grokReconcileFixtures()[2]
  493. repo := &grokReconcileRepo{accounts: []Account{account}}
  494. refresher := &poolHealthRefresher{err: errors.New(`GROK_OAUTH_ENTITLEMENT_DENIED: subscription required`)}
  495. svc := newGrokReconcileService(repo, refresher, &reconcileInvalidator{})
  496. result, err := svc.ReconcileGrokOAuth(context.Background(), GrokOAuthReconcileInput{Apply: true, Limit: 50})
  497. require.NoError(t, err)
  498. require.Equal(t, 1, result.Blocked)
  499. require.Zero(t, result.Failed)
  500. require.Zero(t, result.Partial)
  501. require.Equal(t, GrokOAuthReconcileActionBlock, result.Items[0].Action)
  502. require.Equal(t, GrokOAuthReconcileReasonCredentialRejected, result.Items[0].Reason)
  503. require.Equal(t, GrokOAuthReconcileOutcomeApplied, result.Items[0].Outcome)
  504. _, setErrorIDs, _, _ := repo.snapshot()
  505. require.Equal(t, []int64{account.ID}, setErrorIDs)
  506. }
  507. func TestTokenRefreshService_ReconcileGrokOAuthReportsConcurrentRefreshReauthorizationAsSkipped(t *testing.T) {
  508. account := grokReconcileFixtures()[2]
  509. repo := &grokReconcileRepo{
  510. accounts: []Account{account},
  511. reauthorizeOnRefreshCAS: true,
  512. }
  513. invalidator := &reconcileInvalidator{}
  514. refresher := &poolHealthRefresher{err: errors.New("invalid_grant: revoked")}
  515. svc := newGrokReconcileService(repo, refresher, invalidator)
  516. result, err := svc.ReconcileGrokOAuth(context.Background(), GrokOAuthReconcileInput{Apply: true, Limit: 50})
  517. require.NoError(t, err)
  518. require.Equal(t, 1, result.Skipped)
  519. require.Zero(t, result.Failed)
  520. require.Zero(t, result.Blocked)
  521. require.Equal(t, GrokOAuthReconcileOutcomeSkipped, result.Items[0].Outcome)
  522. require.Zero(t, invalidator.count())
  523. _, setErrorIDs, _, _ := repo.snapshot()
  524. require.Empty(t, setErrorIDs)
  525. latest, getErr := repo.GetByID(context.Background(), account.ID)
  526. require.NoError(t, getErr)
  527. require.Equal(t, StatusActive, latest.Status)
  528. require.Equal(t, "fresh-refresh", latest.GetGrokRefreshToken())
  529. }
  530. func TestTokenRefreshService_ReconcileGrokOAuthReportsInvalidationFailureAsPartial(t *testing.T) {
  531. account := grokReconcileFixtures()[0]
  532. repo := &grokReconcileRepo{accounts: []Account{account}}
  533. svc := newGrokReconcileService(repo, &poolHealthRefresher{}, &reconcileInvalidator{err: errors.New("cache unavailable")})
  534. result, err := svc.ReconcileGrokOAuth(context.Background(), GrokOAuthReconcileInput{Apply: true, Limit: 50})
  535. require.NoError(t, err)
  536. require.Equal(t, 1, result.Blocked)
  537. require.Equal(t, 1, result.Partial)
  538. require.Zero(t, result.Failed)
  539. require.Equal(t, GrokOAuthReconcileOutcomePartial, result.Items[0].Outcome)
  540. }