grok_token_provider_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "sync"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  10. "github.com/stretchr/testify/require"
  11. )
  12. type grokTokenCacheForProviderTest struct {
  13. token string
  14. setKey string
  15. setToken string
  16. setTTL time.Duration
  17. lockResult bool
  18. releaseCalls int
  19. deletedKeys []string
  20. deleteErr error
  21. getCalls int
  22. mu sync.Mutex
  23. }
  24. type grokCredentialRaceRepo struct {
  25. *tokenRefreshAccountRepo
  26. mu sync.RWMutex
  27. }
  28. func (r *grokCredentialRaceRepo) GetByID(ctx context.Context, id int64) (*Account, error) {
  29. r.mu.RLock()
  30. defer r.mu.RUnlock()
  31. return r.tokenRefreshAccountRepo.GetByID(ctx, id)
  32. }
  33. func (r *grokCredentialRaceRepo) setAccount(account *Account) {
  34. r.mu.Lock()
  35. defer r.mu.Unlock()
  36. r.accountsByID[account.ID] = account
  37. }
  38. func (c *grokTokenCacheForProviderTest) GetAccessToken(context.Context, string) (string, error) {
  39. c.mu.Lock()
  40. defer c.mu.Unlock()
  41. c.getCalls++
  42. if c.token == "" {
  43. return "", errors.New("not cached")
  44. }
  45. return c.token, nil
  46. }
  47. func (c *grokTokenCacheForProviderTest) SetAccessToken(_ context.Context, key string, token string, ttl time.Duration) error {
  48. c.mu.Lock()
  49. defer c.mu.Unlock()
  50. c.setKey = key
  51. c.setToken = token
  52. c.setTTL = ttl
  53. return nil
  54. }
  55. func (c *grokTokenCacheForProviderTest) DeleteAccessToken(_ context.Context, key string) error {
  56. c.mu.Lock()
  57. defer c.mu.Unlock()
  58. c.deletedKeys = append(c.deletedKeys, key)
  59. return c.deleteErr
  60. }
  61. func (c *grokTokenCacheForProviderTest) AcquireRefreshLock(context.Context, string, time.Duration) (bool, error) {
  62. return c.lockResult, nil
  63. }
  64. func (c *grokTokenCacheForProviderTest) ReleaseRefreshLock(context.Context, string) error {
  65. c.releaseCalls++
  66. return nil
  67. }
  68. func TestGrokTokenProviderRefreshesExpiredTokenOnRequestPath(t *testing.T) {
  69. t.Setenv(xai.EnvBaseURL, xai.DefaultCLIBaseURL)
  70. expiredAt := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
  71. account := &Account{
  72. ID: 54,
  73. Platform: PlatformGrok,
  74. Type: AccountTypeOAuth,
  75. Status: StatusActive,
  76. Schedulable: true,
  77. Credentials: map[string]any{
  78. "access_token": "expired-access-token",
  79. "refresh_token": "refresh-token",
  80. "expires_at": expiredAt,
  81. "base_url": xai.DefaultCLIBaseURL,
  82. "client_id": "client-id",
  83. },
  84. }
  85. repo := &tokenRefreshAccountRepo{}
  86. repo.accountsByID = map[int64]*Account{54: account}
  87. cache := &grokTokenCacheForProviderTest{lockResult: true}
  88. oauthSvc := NewGrokOAuthService(nil, &grokOAuthClientStub{
  89. refreshResponse: &xai.TokenResponse{
  90. AccessToken: "new-access-token",
  91. TokenType: "Bearer",
  92. ExpiresIn: 3600,
  93. },
  94. })
  95. defer oauthSvc.Stop()
  96. provider := NewGrokTokenProvider(repo, cache)
  97. provider.SetRefreshAPI(NewOAuthRefreshAPI(repo, cache), NewGrokTokenRefresher(oauthSvc))
  98. token, err := provider.GetAccessToken(context.Background(), account)
  99. require.NoError(t, err)
  100. require.Equal(t, "new-access-token", token)
  101. require.Equal(t, 1, repo.updateCredentialsCalls)
  102. require.Equal(t, "new-access-token", repo.accountsByID[54].GetGrokAccessToken())
  103. require.Equal(t, "refresh-token", repo.accountsByID[54].GetGrokRefreshToken())
  104. require.Equal(t, xai.DefaultCLIBaseURL, repo.accountsByID[54].GetGrokBaseURL())
  105. require.Equal(t, "grok:account:54", cache.setKey)
  106. require.Equal(t, "new-access-token", cache.setToken)
  107. require.Greater(t, cache.setTTL, time.Duration(0))
  108. require.Equal(t, 1, cache.releaseCalls)
  109. }
  110. func TestGrokTokenProviderRefreshFailureUnschedulesWithRedactedReason(t *testing.T) {
  111. expiredAt := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
  112. account := &Account{
  113. ID: 55,
  114. Platform: PlatformGrok,
  115. Type: AccountTypeOAuth,
  116. Status: StatusActive,
  117. Schedulable: true,
  118. Credentials: map[string]any{
  119. "access_token": "expired-access-token",
  120. "refresh_token": "refresh-token",
  121. "expires_at": expiredAt,
  122. "base_url": xai.DefaultCLIBaseURL,
  123. },
  124. }
  125. repo := &tokenRefreshAccountRepo{}
  126. repo.accountsByID = map[int64]*Account{55: account}
  127. cache := &grokTokenCacheForProviderTest{lockResult: true}
  128. provider := NewGrokTokenProvider(repo, cache)
  129. provider.SetRefreshAPI(NewOAuthRefreshAPI(repo, cache), &tokenRefresherStub{
  130. err: errors.New("temporary refresh failure access_token=leaked-access refresh_token=leaked-refresh"),
  131. })
  132. token, err := provider.GetAccessToken(context.Background(), account)
  133. require.Error(t, err)
  134. require.Empty(t, token)
  135. require.Equal(t, 0, repo.setTempUnschedCalls)
  136. require.Equal(t, 0, repo.setErrorCalls)
  137. }
  138. func TestGrokTokenProviderLockHeldWaitsForRefreshedCacheAndNeverUsesExpiredToken(t *testing.T) {
  139. account := expiredGrokOAuthAccountForCredentialTest(56)
  140. baseRepo := &tokenRefreshAccountRepo{}
  141. baseRepo.accountsByID = map[int64]*Account{account.ID: account}
  142. repo := &grokCredentialRaceRepo{tokenRefreshAccountRepo: baseRepo}
  143. cache := &grokTokenCacheForProviderTest{lockResult: false, token: "expired-access-token"}
  144. provider := NewGrokTokenProvider(repo, cache)
  145. provider.SetRefreshAPI(NewOAuthRefreshAPI(repo, cache), &tokenRefresherStub{})
  146. go func() {
  147. time.Sleep(40 * time.Millisecond)
  148. refreshed := *account
  149. refreshed.Credentials = shallowCopyMap(account.Credentials)
  150. refreshed.Credentials["access_token"] = "refreshed-after-lock"
  151. refreshed.Credentials["expires_at"] = time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
  152. refreshed.Credentials["_token_version"] = time.Now().UnixMilli()
  153. repo.setAccount(&refreshed)
  154. cache.mu.Lock()
  155. cache.token = "refreshed-after-lock"
  156. cache.mu.Unlock()
  157. }()
  158. startedAt := time.Now()
  159. token, err := provider.GetAccessToken(context.Background(), account)
  160. require.NoError(t, err)
  161. require.Equal(t, "refreshed-after-lock", token)
  162. require.NotEqual(t, "expired-access-token", token)
  163. require.GreaterOrEqual(t, time.Since(startedAt), 25*time.Millisecond,
  164. "expired account metadata must prevent returning the old cached token")
  165. }
  166. func TestGrokTokenProviderLockHeldTimeoutDoesNotReturnExpiredToken(t *testing.T) {
  167. account := expiredGrokOAuthAccountForCredentialTest(57)
  168. repo := &tokenRefreshAccountRepo{}
  169. repo.accountsByID = map[int64]*Account{account.ID: account}
  170. cache := &grokTokenCacheForProviderTest{lockResult: false}
  171. provider := NewGrokTokenProvider(repo, cache)
  172. provider.SetRefreshAPI(NewOAuthRefreshAPI(repo, cache), &tokenRefresherStub{})
  173. ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
  174. defer cancel()
  175. token, err := provider.GetAccessToken(ctx, account)
  176. require.Error(t, err)
  177. require.Empty(t, token)
  178. }
  179. func TestGrokTokenProviderLockHeldRejectsChangedTokenWithoutExpiry(t *testing.T) {
  180. account := expiredGrokOAuthAccountForCredentialTest(58)
  181. baseRepo := &tokenRefreshAccountRepo{}
  182. baseRepo.accountsByID = map[int64]*Account{account.ID: account}
  183. repo := &grokCredentialRaceRepo{tokenRefreshAccountRepo: baseRepo}
  184. cache := &grokTokenCacheForProviderTest{lockResult: false, token: "expired-access-token"}
  185. provider := NewGrokTokenProvider(repo, cache)
  186. provider.SetRefreshAPI(NewOAuthRefreshAPI(repo, cache), &tokenRefresherStub{})
  187. go func() {
  188. time.Sleep(30 * time.Millisecond)
  189. refreshed := *account
  190. refreshed.Credentials = shallowCopyMap(account.Credentials)
  191. refreshed.Credentials["access_token"] = "changed-without-expiry"
  192. delete(refreshed.Credentials, "expires_at")
  193. refreshed.Credentials["_token_version"] = time.Now().UnixMilli()
  194. repo.setAccount(&refreshed)
  195. cache.mu.Lock()
  196. cache.token = "changed-without-expiry"
  197. cache.mu.Unlock()
  198. }()
  199. ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
  200. defer cancel()
  201. token, err := provider.GetAccessToken(ctx, account)
  202. require.Error(t, err)
  203. require.Empty(t, token, "an unbounded credential must not win the lock-held race")
  204. }
  205. func TestGrokTokenProviderLockHeldUsesVersionedDBTokenAndRepairsStaleCache(t *testing.T) {
  206. account := expiredGrokOAuthAccountForCredentialTest(60)
  207. baseRepo := &tokenRefreshAccountRepo{}
  208. baseRepo.accountsByID = map[int64]*Account{account.ID: account}
  209. repo := &grokCredentialRaceRepo{tokenRefreshAccountRepo: baseRepo}
  210. cache := &grokTokenCacheForProviderTest{lockResult: false, token: "expired-access-token"}
  211. provider := NewGrokTokenProvider(repo, cache)
  212. provider.SetRefreshAPI(NewOAuthRefreshAPI(repo, cache), &tokenRefresherStub{})
  213. go func() {
  214. time.Sleep(30 * time.Millisecond)
  215. refreshed := *account
  216. refreshed.Credentials = shallowCopyMap(account.Credentials)
  217. refreshed.Credentials["access_token"] = "db-authoritative-token"
  218. refreshed.Credentials["expires_at"] = time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339)
  219. refreshed.Credentials["_token_version"] = time.Now().UnixMilli()
  220. repo.setAccount(&refreshed)
  221. }()
  222. token, err := provider.GetAccessToken(context.Background(), account)
  223. require.NoError(t, err)
  224. require.Equal(t, "db-authoritative-token", token)
  225. require.Equal(t, "db-authoritative-token", cache.setToken)
  226. require.Greater(t, cache.setTTL, time.Duration(0))
  227. }
  228. func TestGrokTokenProviderRejectsStaleDBTokenWithoutExpiry(t *testing.T) {
  229. expiresAt := time.Now().Add(2 * grokTokenRefreshSkew).UTC().Format(time.RFC3339)
  230. account := &Account{
  231. ID: 59,
  232. Platform: PlatformGrok,
  233. Type: AccountTypeOAuth,
  234. Status: StatusActive,
  235. Schedulable: true,
  236. Credentials: map[string]any{
  237. "access_token": "old-access-token",
  238. "refresh_token": "refresh-token",
  239. "expires_at": expiresAt,
  240. },
  241. }
  242. latest := *account
  243. latest.Credentials = shallowCopyMap(account.Credentials)
  244. latest.Credentials["access_token"] = "new-access-token-without-expiry"
  245. latest.Credentials["_token_version"] = time.Now().UnixMilli()
  246. delete(latest.Credentials, "expires_at")
  247. repo := &tokenRefreshAccountRepo{}
  248. repo.accountsByID = map[int64]*Account{account.ID: &latest}
  249. cache := &grokTokenCacheForProviderTest{}
  250. provider := NewGrokTokenProvider(repo, cache)
  251. token, err := provider.GetAccessToken(context.Background(), account)
  252. require.ErrorIs(t, err, errGrokOAuthAccessTokenExpired)
  253. require.Empty(t, token)
  254. }
  255. // TestGrokTokenProviderManualTestBypassesSchedulingGate reproduces #4598:
  256. // admins must be able to run "test connection" against accounts that the
  257. // scheduler currently excludes (manual switch off, rate limited, overloaded,
  258. // temporarily cooled down). The production request path keeps rejecting them.
  259. func TestGrokTokenProviderManualTestBypassesSchedulingGate(t *testing.T) {
  260. future := time.Now().Add(time.Hour)
  261. tests := []struct {
  262. name string
  263. mutate func(*Account)
  264. }{
  265. {name: "not schedulable", mutate: func(account *Account) { account.Schedulable = false }},
  266. {name: "temporarily unschedulable", mutate: func(account *Account) { account.TempUnschedulableUntil = &future }},
  267. {name: "rate limited", mutate: func(account *Account) { account.RateLimitResetAt = &future }},
  268. {name: "overloaded", mutate: func(account *Account) { account.OverloadUntil = &future }},
  269. {name: "disabled by error", mutate: func(account *Account) { account.Status = StatusError }},
  270. }
  271. for index, tt := range tests {
  272. t.Run(tt.name, func(t *testing.T) {
  273. account := expiredGrokOAuthAccountForCredentialTest(int64(120 + index))
  274. account.Credentials["access_token"] = "still-valid-token"
  275. account.Credentials["expires_at"] = time.Now().Add(2 * grokTokenRefreshSkew).UTC().Format(time.RFC3339)
  276. tt.mutate(account)
  277. provider := NewGrokTokenProvider(&tokenRefreshAccountRepo{}, &grokTokenCacheForProviderTest{})
  278. // Production request path keeps excluding this account.
  279. _, requestErr := provider.GetAccessToken(context.Background(), account)
  280. require.ErrorIs(t, requestErr, errOAuthRefreshAccountStateChanged)
  281. // Manual test path returns the valid credential for probing.
  282. token, err := provider.GetAccessTokenForManualTest(context.Background(), account)
  283. require.NoError(t, err)
  284. require.Equal(t, "still-valid-token", token)
  285. })
  286. }
  287. }
  288. func TestGrokTokenProviderManualTestRefreshesExpiredTokenWhileUnschedulable(t *testing.T) {
  289. t.Setenv(xai.EnvBaseURL, xai.DefaultCLIBaseURL)
  290. expiredAt := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
  291. account := &Account{
  292. ID: 130,
  293. Platform: PlatformGrok,
  294. Type: AccountTypeOAuth,
  295. Status: StatusActive,
  296. Schedulable: false,
  297. Credentials: map[string]any{
  298. "access_token": "expired-access-token",
  299. "refresh_token": "refresh-token",
  300. "expires_at": expiredAt,
  301. "base_url": xai.DefaultCLIBaseURL,
  302. "client_id": "client-id",
  303. },
  304. }
  305. repo := &tokenRefreshAccountRepo{}
  306. repo.accountsByID = map[int64]*Account{130: account}
  307. cache := &grokTokenCacheForProviderTest{lockResult: true}
  308. oauthSvc := NewGrokOAuthService(nil, &grokOAuthClientStub{
  309. refreshResponse: &xai.TokenResponse{
  310. AccessToken: "manual-test-refreshed-token",
  311. TokenType: "Bearer",
  312. ExpiresIn: 3600,
  313. },
  314. })
  315. defer oauthSvc.Stop()
  316. provider := NewGrokTokenProvider(repo, cache)
  317. provider.SetRefreshAPI(NewOAuthRefreshAPI(repo, cache), NewGrokTokenRefresher(oauthSvc))
  318. token, err := provider.GetAccessTokenForManualTest(context.Background(), account)
  319. require.NoError(t, err)
  320. require.Equal(t, "manual-test-refreshed-token", token)
  321. require.Equal(t, 1, repo.updateCredentialsCalls)
  322. }
  323. func TestGrokTokenProviderManualTestFallsBackToValidTokenOnRefreshFailure(t *testing.T) {
  324. account := &Account{
  325. ID: 131,
  326. Platform: PlatformGrok,
  327. Type: AccountTypeOAuth,
  328. Status: StatusActive,
  329. Schedulable: false,
  330. Credentials: map[string]any{
  331. "access_token": "near-expiry-token",
  332. "refresh_token": "refresh-token",
  333. // Inside the refresh window but not expired yet.
  334. "expires_at": time.Now().Add(10 * time.Minute).UTC().Format(time.RFC3339),
  335. },
  336. }
  337. repo := &tokenRefreshAccountRepo{}
  338. repo.accountsByID = map[int64]*Account{131: account}
  339. cache := &grokTokenCacheForProviderTest{lockResult: true}
  340. provider := NewGrokTokenProvider(repo, cache)
  341. provider.SetRefreshAPI(NewOAuthRefreshAPI(repo, cache), &tokenRefresherStub{
  342. err: errors.New("upstream refresh unavailable"),
  343. })
  344. token, err := provider.GetAccessTokenForManualTest(context.Background(), account)
  345. require.NoError(t, err)
  346. require.Equal(t, "near-expiry-token", token)
  347. }
  348. func TestGrokTokenProviderManualTestReportsRefreshFailureWhenTokenExpired(t *testing.T) {
  349. account := expiredGrokOAuthAccountForCredentialTest(132)
  350. account.Schedulable = false
  351. repo := &tokenRefreshAccountRepo{}
  352. repo.accountsByID = map[int64]*Account{account.ID: account}
  353. cache := &grokTokenCacheForProviderTest{lockResult: true}
  354. provider := NewGrokTokenProvider(repo, cache)
  355. provider.SetRefreshAPI(NewOAuthRefreshAPI(repo, cache), &tokenRefresherStub{
  356. err: errors.New("invalid_client: client credentials rejected"),
  357. })
  358. token, err := provider.GetAccessTokenForManualTest(context.Background(), account)
  359. require.Error(t, err)
  360. require.Empty(t, token)
  361. require.Contains(t, err.Error(), "invalid_client")
  362. }
  363. func TestGrokTokenProviderManualTestLockHeldWithExpiredTokenReturnsSpecificError(t *testing.T) {
  364. account := expiredGrokOAuthAccountForCredentialTest(133)
  365. repo := &tokenRefreshAccountRepo{}
  366. repo.accountsByID = map[int64]*Account{account.ID: account}
  367. cache := &grokTokenCacheForProviderTest{lockResult: false}
  368. provider := NewGrokTokenProvider(repo, cache)
  369. provider.SetRefreshAPI(NewOAuthRefreshAPI(repo, cache), &tokenRefresherStub{})
  370. token, err := provider.GetAccessTokenForManualTest(context.Background(), account)
  371. require.Error(t, err)
  372. require.Empty(t, token)
  373. require.Contains(t, err.Error(), "refresh is already in progress")
  374. }
  375. func TestGrokTokenProviderManualTestRequiresRefreshToken(t *testing.T) {
  376. account := expiredGrokOAuthAccountForCredentialTest(134)
  377. delete(account.Credentials, "refresh_token")
  378. provider := NewGrokTokenProvider(&tokenRefreshAccountRepo{}, &grokTokenCacheForProviderTest{})
  379. token, err := provider.GetAccessTokenForManualTest(context.Background(), account)
  380. require.ErrorIs(t, err, errGrokOAuthRefreshTokenMissing)
  381. require.Empty(t, token)
  382. }
  383. func TestGrokTokenProviderRejectsIneligibleSelectedAccountBeforeWarmCache(t *testing.T) {
  384. future := time.Now().Add(time.Hour)
  385. tests := []struct {
  386. name string
  387. mutate func(*Account)
  388. }{
  389. {name: "disabled", mutate: func(account *Account) { account.Status = StatusDisabled }},
  390. {name: "not schedulable", mutate: func(account *Account) { account.Schedulable = false }},
  391. {name: "temporarily unschedulable", mutate: func(account *Account) { account.TempUnschedulableUntil = &future }},
  392. {name: "rate limited", mutate: func(account *Account) { account.RateLimitResetAt = &future }},
  393. {name: "overloaded", mutate: func(account *Account) { account.OverloadUntil = &future }},
  394. }
  395. for index, tt := range tests {
  396. t.Run(tt.name, func(t *testing.T) {
  397. account := expiredGrokOAuthAccountForCredentialTest(int64(90 + index))
  398. account.Credentials["access_token"] = "warm-cache-token"
  399. account.Credentials["expires_at"] = time.Now().Add(2 * grokTokenRefreshSkew).UTC().Format(time.RFC3339)
  400. tt.mutate(account)
  401. cache := &grokTokenCacheForProviderTest{token: "warm-cache-token"}
  402. provider := NewGrokTokenProvider(&tokenRefreshAccountRepo{}, cache)
  403. token, err := provider.GetAccessToken(context.Background(), account)
  404. require.ErrorIs(t, err, errOAuthRefreshAccountStateChanged)
  405. require.Empty(t, token)
  406. require.Zero(t, cache.getCalls, "an ineligible selected account must be rejected before cache lookup")
  407. })
  408. }
  409. }