claude_token_provider_test.go 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "sync"
  7. "sync/atomic"
  8. "testing"
  9. "time"
  10. "github.com/stretchr/testify/require"
  11. )
  12. // claudeTokenCacheStub implements ClaudeTokenCache for testing
  13. type claudeTokenCacheStub struct {
  14. mu sync.Mutex
  15. tokens map[string]string
  16. getErr error
  17. setErr error
  18. deleteErr error
  19. lockAcquired bool
  20. lockErr error
  21. releaseLockErr error
  22. getCalled int32
  23. setCalled int32
  24. lockCalled int32
  25. unlockCalled int32
  26. simulateLockRace bool
  27. }
  28. func newClaudeTokenCacheStub() *claudeTokenCacheStub {
  29. return &claudeTokenCacheStub{
  30. tokens: make(map[string]string),
  31. lockAcquired: true,
  32. }
  33. }
  34. func (s *claudeTokenCacheStub) GetAccessToken(ctx context.Context, cacheKey string) (string, error) {
  35. atomic.AddInt32(&s.getCalled, 1)
  36. if s.getErr != nil {
  37. return "", s.getErr
  38. }
  39. s.mu.Lock()
  40. defer s.mu.Unlock()
  41. return s.tokens[cacheKey], nil
  42. }
  43. func (s *claudeTokenCacheStub) SetAccessToken(ctx context.Context, cacheKey string, token string, ttl time.Duration) error {
  44. atomic.AddInt32(&s.setCalled, 1)
  45. if s.setErr != nil {
  46. return s.setErr
  47. }
  48. s.mu.Lock()
  49. defer s.mu.Unlock()
  50. s.tokens[cacheKey] = token
  51. return nil
  52. }
  53. func (s *claudeTokenCacheStub) DeleteAccessToken(ctx context.Context, cacheKey string) error {
  54. if s.deleteErr != nil {
  55. return s.deleteErr
  56. }
  57. s.mu.Lock()
  58. defer s.mu.Unlock()
  59. delete(s.tokens, cacheKey)
  60. return nil
  61. }
  62. func (s *claudeTokenCacheStub) AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (bool, error) {
  63. atomic.AddInt32(&s.lockCalled, 1)
  64. if s.lockErr != nil {
  65. return false, s.lockErr
  66. }
  67. if s.simulateLockRace {
  68. return false, nil
  69. }
  70. return s.lockAcquired, nil
  71. }
  72. func (s *claudeTokenCacheStub) ReleaseRefreshLock(ctx context.Context, cacheKey string) error {
  73. atomic.AddInt32(&s.unlockCalled, 1)
  74. return s.releaseLockErr
  75. }
  76. // claudeAccountRepoStub is a minimal stub implementing only the methods used by ClaudeTokenProvider
  77. type claudeAccountRepoStub struct {
  78. account *Account
  79. getErr error
  80. updateErr error
  81. getCalled int32
  82. updateCalled int32
  83. }
  84. func (r *claudeAccountRepoStub) GetByID(ctx context.Context, id int64) (*Account, error) {
  85. atomic.AddInt32(&r.getCalled, 1)
  86. if r.getErr != nil {
  87. return nil, r.getErr
  88. }
  89. return r.account, nil
  90. }
  91. func (r *claudeAccountRepoStub) Update(ctx context.Context, account *Account) error {
  92. atomic.AddInt32(&r.updateCalled, 1)
  93. if r.updateErr != nil {
  94. return r.updateErr
  95. }
  96. r.account = account
  97. return nil
  98. }
  99. // claudeOAuthServiceStub implements OAuthService methods for testing
  100. type claudeOAuthServiceStub struct {
  101. tokenInfo *TokenInfo
  102. refreshErr error
  103. refreshCalled int32
  104. }
  105. func (s *claudeOAuthServiceStub) RefreshAccountToken(ctx context.Context, account *Account) (*TokenInfo, error) {
  106. atomic.AddInt32(&s.refreshCalled, 1)
  107. if s.refreshErr != nil {
  108. return nil, s.refreshErr
  109. }
  110. return s.tokenInfo, nil
  111. }
  112. // testClaudeTokenProvider is a test version that uses the stub OAuth service
  113. type testClaudeTokenProvider struct {
  114. accountRepo *claudeAccountRepoStub
  115. tokenCache *claudeTokenCacheStub
  116. oauthService *claudeOAuthServiceStub
  117. }
  118. func (p *testClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Account) (string, error) {
  119. if account == nil {
  120. return "", errors.New("account is nil")
  121. }
  122. if account.Platform != PlatformAnthropic || account.Type != AccountTypeOAuth {
  123. return "", errors.New("not an anthropic oauth or service account")
  124. }
  125. cacheKey := ClaudeTokenCacheKey(account)
  126. // 1. Check cache
  127. if p.tokenCache != nil {
  128. if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && token != "" {
  129. return token, nil
  130. }
  131. }
  132. // 2. Check if refresh needed
  133. expiresAt := account.GetCredentialAsTime("expires_at")
  134. needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= claudeTokenRefreshSkew
  135. refreshFailed := false
  136. if needsRefresh && p.tokenCache != nil {
  137. locked, err := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
  138. if err == nil && locked {
  139. defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }()
  140. // Check cache again after acquiring lock
  141. if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && token != "" {
  142. return token, nil
  143. }
  144. // Get fresh account from DB
  145. fresh, err := p.accountRepo.GetByID(ctx, account.ID)
  146. if err == nil && fresh != nil {
  147. account = fresh
  148. }
  149. expiresAt = account.GetCredentialAsTime("expires_at")
  150. if expiresAt == nil || time.Until(*expiresAt) <= claudeTokenRefreshSkew {
  151. if p.oauthService == nil {
  152. refreshFailed = true // 无法刷新,标记失败
  153. } else {
  154. tokenInfo, err := p.oauthService.RefreshAccountToken(ctx, account)
  155. if err != nil {
  156. refreshFailed = true // 刷新失败,标记以使用短 TTL
  157. } else {
  158. // Build new credentials
  159. newCredentials := make(map[string]any)
  160. for k, v := range account.Credentials {
  161. newCredentials[k] = v
  162. }
  163. newCredentials["access_token"] = tokenInfo.AccessToken
  164. newCredentials["token_type"] = tokenInfo.TokenType
  165. newCredentials["expires_at"] = time.Now().Add(time.Duration(tokenInfo.ExpiresIn) * time.Second).Format(time.RFC3339)
  166. if tokenInfo.RefreshToken != "" {
  167. newCredentials["refresh_token"] = tokenInfo.RefreshToken
  168. }
  169. account.Credentials = newCredentials
  170. _ = p.accountRepo.Update(ctx, account)
  171. expiresAt = account.GetCredentialAsTime("expires_at")
  172. }
  173. }
  174. }
  175. } else if p.tokenCache.simulateLockRace {
  176. // Wait and retry cache
  177. time.Sleep(10 * time.Millisecond)
  178. if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && token != "" {
  179. return token, nil
  180. }
  181. }
  182. }
  183. accessToken := account.GetCredential("access_token")
  184. if accessToken == "" {
  185. return "", errors.New("access_token not found in credentials")
  186. }
  187. // 3. Store in cache
  188. if p.tokenCache != nil {
  189. ttl := 30 * time.Minute
  190. if refreshFailed {
  191. ttl = time.Minute // 刷新失败时使用短 TTL
  192. } else if expiresAt != nil {
  193. until := time.Until(*expiresAt)
  194. if until > claudeTokenCacheSkew {
  195. ttl = until - claudeTokenCacheSkew
  196. } else if until > 0 {
  197. ttl = until
  198. } else {
  199. ttl = time.Minute
  200. }
  201. }
  202. _ = p.tokenCache.SetAccessToken(ctx, cacheKey, accessToken, ttl)
  203. }
  204. return accessToken, nil
  205. }
  206. func TestClaudeTokenProvider_CacheHit(t *testing.T) {
  207. cache := newClaudeTokenCacheStub()
  208. account := &Account{
  209. ID: 100,
  210. Platform: PlatformAnthropic,
  211. Type: AccountTypeOAuth,
  212. Credentials: map[string]any{
  213. "access_token": "db-token",
  214. },
  215. }
  216. cacheKey := ClaudeTokenCacheKey(account)
  217. cache.tokens[cacheKey] = "cached-token"
  218. provider := NewClaudeTokenProvider(nil, cache, nil)
  219. token, err := provider.GetAccessToken(context.Background(), account)
  220. require.NoError(t, err)
  221. require.Equal(t, "cached-token", token)
  222. require.Equal(t, int32(1), atomic.LoadInt32(&cache.getCalled))
  223. require.Equal(t, int32(0), atomic.LoadInt32(&cache.setCalled))
  224. }
  225. func TestClaudeTokenProvider_CacheMiss_FromCredentials(t *testing.T) {
  226. cache := newClaudeTokenCacheStub()
  227. // Token expires in far future, no refresh needed
  228. expiresAt := time.Now().Add(1 * time.Hour).Format(time.RFC3339)
  229. account := &Account{
  230. ID: 101,
  231. Platform: PlatformAnthropic,
  232. Type: AccountTypeOAuth,
  233. Credentials: map[string]any{
  234. "access_token": "credential-token",
  235. "expires_at": expiresAt,
  236. },
  237. }
  238. provider := NewClaudeTokenProvider(nil, cache, nil)
  239. token, err := provider.GetAccessToken(context.Background(), account)
  240. require.NoError(t, err)
  241. require.Equal(t, "credential-token", token)
  242. // Should have stored in cache
  243. cacheKey := ClaudeTokenCacheKey(account)
  244. require.Equal(t, "credential-token", cache.tokens[cacheKey])
  245. }
  246. func TestClaudeTokenProvider_TokenRefresh(t *testing.T) {
  247. cache := newClaudeTokenCacheStub()
  248. accountRepo := &claudeAccountRepoStub{}
  249. oauthService := &claudeOAuthServiceStub{
  250. tokenInfo: &TokenInfo{
  251. AccessToken: "refreshed-token",
  252. RefreshToken: "new-refresh-token",
  253. TokenType: "Bearer",
  254. ExpiresIn: 3600,
  255. ExpiresAt: time.Now().Add(time.Hour).Unix(),
  256. },
  257. }
  258. // Token expires soon (within refresh skew)
  259. expiresAt := time.Now().Add(1 * time.Minute).Format(time.RFC3339)
  260. account := &Account{
  261. ID: 102,
  262. Platform: PlatformAnthropic,
  263. Type: AccountTypeOAuth,
  264. Credentials: map[string]any{
  265. "access_token": "old-token",
  266. "refresh_token": "old-refresh-token",
  267. "expires_at": expiresAt,
  268. },
  269. }
  270. accountRepo.account = account
  271. provider := &testClaudeTokenProvider{
  272. accountRepo: accountRepo,
  273. tokenCache: cache,
  274. oauthService: oauthService,
  275. }
  276. token, err := provider.GetAccessToken(context.Background(), account)
  277. require.NoError(t, err)
  278. require.Equal(t, "refreshed-token", token)
  279. require.Equal(t, int32(1), atomic.LoadInt32(&oauthService.refreshCalled))
  280. }
  281. func TestClaudeTokenProvider_LockRaceCondition(t *testing.T) {
  282. cache := newClaudeTokenCacheStub()
  283. cache.simulateLockRace = true
  284. accountRepo := &claudeAccountRepoStub{}
  285. // Token expires soon
  286. expiresAt := time.Now().Add(1 * time.Minute).Format(time.RFC3339)
  287. account := &Account{
  288. ID: 103,
  289. Platform: PlatformAnthropic,
  290. Type: AccountTypeOAuth,
  291. Credentials: map[string]any{
  292. "access_token": "race-token",
  293. "expires_at": expiresAt,
  294. },
  295. }
  296. accountRepo.account = account
  297. // Simulate another worker already refreshed and cached
  298. cacheKey := ClaudeTokenCacheKey(account)
  299. go func() {
  300. time.Sleep(5 * time.Millisecond)
  301. cache.mu.Lock()
  302. cache.tokens[cacheKey] = "winner-token"
  303. cache.mu.Unlock()
  304. }()
  305. provider := &testClaudeTokenProvider{
  306. accountRepo: accountRepo,
  307. tokenCache: cache,
  308. }
  309. token, err := provider.GetAccessToken(context.Background(), account)
  310. require.NoError(t, err)
  311. require.NotEmpty(t, token)
  312. }
  313. func TestClaudeTokenProvider_NilAccount(t *testing.T) {
  314. provider := NewClaudeTokenProvider(nil, nil, nil)
  315. token, err := provider.GetAccessToken(context.Background(), nil)
  316. require.Error(t, err)
  317. require.Contains(t, err.Error(), "account is nil")
  318. require.Empty(t, token)
  319. }
  320. func TestClaudeTokenProvider_WrongPlatform(t *testing.T) {
  321. provider := NewClaudeTokenProvider(nil, nil, nil)
  322. account := &Account{
  323. ID: 104,
  324. Platform: PlatformOpenAI,
  325. Type: AccountTypeOAuth,
  326. }
  327. token, err := provider.GetAccessToken(context.Background(), account)
  328. require.Error(t, err)
  329. require.Contains(t, err.Error(), "not an anthropic oauth or service account")
  330. require.Empty(t, token)
  331. }
  332. func TestClaudeTokenProvider_WrongAccountType(t *testing.T) {
  333. provider := NewClaudeTokenProvider(nil, nil, nil)
  334. account := &Account{
  335. ID: 105,
  336. Platform: PlatformAnthropic,
  337. Type: AccountTypeAPIKey,
  338. }
  339. token, err := provider.GetAccessToken(context.Background(), account)
  340. require.Error(t, err)
  341. require.Contains(t, err.Error(), "not an anthropic oauth or service account")
  342. require.Empty(t, token)
  343. }
  344. func TestClaudeTokenProvider_SetupTokenType(t *testing.T) {
  345. provider := NewClaudeTokenProvider(nil, nil, nil)
  346. account := &Account{
  347. ID: 106,
  348. Platform: PlatformAnthropic,
  349. Type: AccountTypeSetupToken,
  350. }
  351. token, err := provider.GetAccessToken(context.Background(), account)
  352. require.Error(t, err)
  353. require.Contains(t, err.Error(), "not an anthropic oauth or service account")
  354. require.Empty(t, token)
  355. }
  356. func TestClaudeTokenProvider_NilCache(t *testing.T) {
  357. // Token doesn't need refresh
  358. expiresAt := time.Now().Add(1 * time.Hour).Format(time.RFC3339)
  359. account := &Account{
  360. ID: 107,
  361. Platform: PlatformAnthropic,
  362. Type: AccountTypeOAuth,
  363. Credentials: map[string]any{
  364. "access_token": "nocache-token",
  365. "expires_at": expiresAt,
  366. },
  367. }
  368. provider := NewClaudeTokenProvider(nil, nil, nil)
  369. token, err := provider.GetAccessToken(context.Background(), account)
  370. require.NoError(t, err)
  371. require.Equal(t, "nocache-token", token)
  372. }
  373. func TestClaudeTokenProvider_CacheGetError(t *testing.T) {
  374. cache := newClaudeTokenCacheStub()
  375. cache.getErr = errors.New("redis connection failed")
  376. // Token doesn't need refresh
  377. expiresAt := time.Now().Add(1 * time.Hour).Format(time.RFC3339)
  378. account := &Account{
  379. ID: 108,
  380. Platform: PlatformAnthropic,
  381. Type: AccountTypeOAuth,
  382. Credentials: map[string]any{
  383. "access_token": "fallback-token",
  384. "expires_at": expiresAt,
  385. },
  386. }
  387. provider := NewClaudeTokenProvider(nil, cache, nil)
  388. // Should gracefully degrade and return from credentials
  389. token, err := provider.GetAccessToken(context.Background(), account)
  390. require.NoError(t, err)
  391. require.Equal(t, "fallback-token", token)
  392. }
  393. func TestClaudeTokenProvider_CacheSetError(t *testing.T) {
  394. cache := newClaudeTokenCacheStub()
  395. cache.setErr = errors.New("redis write failed")
  396. expiresAt := time.Now().Add(1 * time.Hour).Format(time.RFC3339)
  397. account := &Account{
  398. ID: 109,
  399. Platform: PlatformAnthropic,
  400. Type: AccountTypeOAuth,
  401. Credentials: map[string]any{
  402. "access_token": "still-works-token",
  403. "expires_at": expiresAt,
  404. },
  405. }
  406. provider := NewClaudeTokenProvider(nil, cache, nil)
  407. // Should still work even if cache set fails
  408. token, err := provider.GetAccessToken(context.Background(), account)
  409. require.NoError(t, err)
  410. require.Equal(t, "still-works-token", token)
  411. }
  412. func TestClaudeTokenProvider_MissingAccessToken(t *testing.T) {
  413. cache := newClaudeTokenCacheStub()
  414. expiresAt := time.Now().Add(1 * time.Hour).Format(time.RFC3339)
  415. account := &Account{
  416. ID: 110,
  417. Platform: PlatformAnthropic,
  418. Type: AccountTypeOAuth,
  419. Credentials: map[string]any{
  420. "expires_at": expiresAt,
  421. // missing access_token
  422. },
  423. }
  424. provider := NewClaudeTokenProvider(nil, cache, nil)
  425. token, err := provider.GetAccessToken(context.Background(), account)
  426. require.Error(t, err)
  427. require.Contains(t, err.Error(), "access_token not found")
  428. require.Empty(t, token)
  429. }
  430. func TestClaudeTokenProvider_RefreshError(t *testing.T) {
  431. cache := newClaudeTokenCacheStub()
  432. accountRepo := &claudeAccountRepoStub{}
  433. oauthService := &claudeOAuthServiceStub{
  434. refreshErr: errors.New("oauth refresh failed"),
  435. }
  436. // Token expires soon
  437. expiresAt := time.Now().Add(1 * time.Minute).Format(time.RFC3339)
  438. account := &Account{
  439. ID: 111,
  440. Platform: PlatformAnthropic,
  441. Type: AccountTypeOAuth,
  442. Credentials: map[string]any{
  443. "access_token": "old-token",
  444. "refresh_token": "old-refresh-token",
  445. "expires_at": expiresAt,
  446. },
  447. }
  448. accountRepo.account = account
  449. provider := &testClaudeTokenProvider{
  450. accountRepo: accountRepo,
  451. tokenCache: cache,
  452. oauthService: oauthService,
  453. }
  454. // Now with fallback behavior, should return existing token even if refresh fails
  455. token, err := provider.GetAccessToken(context.Background(), account)
  456. require.NoError(t, err)
  457. require.Equal(t, "old-token", token) // Fallback to existing token
  458. }
  459. func TestClaudeTokenProvider_OAuthServiceNotConfigured(t *testing.T) {
  460. cache := newClaudeTokenCacheStub()
  461. accountRepo := &claudeAccountRepoStub{}
  462. // Token expires soon
  463. expiresAt := time.Now().Add(1 * time.Minute).Format(time.RFC3339)
  464. account := &Account{
  465. ID: 112,
  466. Platform: PlatformAnthropic,
  467. Type: AccountTypeOAuth,
  468. Credentials: map[string]any{
  469. "access_token": "old-token",
  470. "expires_at": expiresAt,
  471. },
  472. }
  473. accountRepo.account = account
  474. provider := &testClaudeTokenProvider{
  475. accountRepo: accountRepo,
  476. tokenCache: cache,
  477. oauthService: nil, // not configured
  478. }
  479. // Now with fallback behavior, should return existing token even if oauth service not configured
  480. token, err := provider.GetAccessToken(context.Background(), account)
  481. require.NoError(t, err)
  482. require.Equal(t, "old-token", token) // Fallback to existing token
  483. }
  484. func TestClaudeTokenProvider_TTLCalculation(t *testing.T) {
  485. tests := []struct {
  486. name string
  487. expiresIn time.Duration
  488. }{
  489. {
  490. name: "far_future_expiry",
  491. expiresIn: 1 * time.Hour,
  492. },
  493. {
  494. name: "medium_expiry",
  495. expiresIn: 10 * time.Minute,
  496. },
  497. {
  498. name: "near_expiry",
  499. expiresIn: 6 * time.Minute,
  500. },
  501. }
  502. for _, tt := range tests {
  503. t.Run(tt.name, func(t *testing.T) {
  504. cache := newClaudeTokenCacheStub()
  505. expiresAt := time.Now().Add(tt.expiresIn).Format(time.RFC3339)
  506. account := &Account{
  507. ID: 200,
  508. Platform: PlatformAnthropic,
  509. Type: AccountTypeOAuth,
  510. Credentials: map[string]any{
  511. "access_token": "test-token",
  512. "expires_at": expiresAt,
  513. },
  514. }
  515. provider := NewClaudeTokenProvider(nil, cache, nil)
  516. _, err := provider.GetAccessToken(context.Background(), account)
  517. require.NoError(t, err)
  518. // Verify token was cached
  519. cacheKey := ClaudeTokenCacheKey(account)
  520. require.Equal(t, "test-token", cache.tokens[cacheKey])
  521. })
  522. }
  523. }
  524. func TestClaudeTokenProvider_AccountRepoGetError(t *testing.T) {
  525. cache := newClaudeTokenCacheStub()
  526. accountRepo := &claudeAccountRepoStub{
  527. getErr: errors.New("db connection failed"),
  528. }
  529. oauthService := &claudeOAuthServiceStub{
  530. tokenInfo: &TokenInfo{
  531. AccessToken: "refreshed-token",
  532. RefreshToken: "new-refresh",
  533. TokenType: "Bearer",
  534. ExpiresIn: 3600,
  535. },
  536. }
  537. // Token expires soon
  538. expiresAt := time.Now().Add(1 * time.Minute).Format(time.RFC3339)
  539. account := &Account{
  540. ID: 113,
  541. Platform: PlatformAnthropic,
  542. Type: AccountTypeOAuth,
  543. Credentials: map[string]any{
  544. "access_token": "old-token",
  545. "refresh_token": "old-refresh",
  546. "expires_at": expiresAt,
  547. },
  548. }
  549. provider := &testClaudeTokenProvider{
  550. accountRepo: accountRepo,
  551. tokenCache: cache,
  552. oauthService: oauthService,
  553. }
  554. // Should still work, just using the passed-in account
  555. token, err := provider.GetAccessToken(context.Background(), account)
  556. require.NoError(t, err)
  557. require.Equal(t, "refreshed-token", token)
  558. }
  559. func TestClaudeTokenProvider_AccountUpdateError(t *testing.T) {
  560. cache := newClaudeTokenCacheStub()
  561. accountRepo := &claudeAccountRepoStub{
  562. updateErr: errors.New("db write failed"),
  563. }
  564. oauthService := &claudeOAuthServiceStub{
  565. tokenInfo: &TokenInfo{
  566. AccessToken: "refreshed-token",
  567. RefreshToken: "new-refresh",
  568. TokenType: "Bearer",
  569. ExpiresIn: 3600,
  570. },
  571. }
  572. // Token expires soon
  573. expiresAt := time.Now().Add(1 * time.Minute).Format(time.RFC3339)
  574. account := &Account{
  575. ID: 114,
  576. Platform: PlatformAnthropic,
  577. Type: AccountTypeOAuth,
  578. Credentials: map[string]any{
  579. "access_token": "old-token",
  580. "refresh_token": "old-refresh",
  581. "expires_at": expiresAt,
  582. },
  583. }
  584. accountRepo.account = account
  585. provider := &testClaudeTokenProvider{
  586. accountRepo: accountRepo,
  587. tokenCache: cache,
  588. oauthService: oauthService,
  589. }
  590. // Should still return token even if update fails
  591. token, err := provider.GetAccessToken(context.Background(), account)
  592. require.NoError(t, err)
  593. require.Equal(t, "refreshed-token", token)
  594. }
  595. func TestClaudeTokenProvider_RefreshPreservesExistingCredentials(t *testing.T) {
  596. cache := newClaudeTokenCacheStub()
  597. accountRepo := &claudeAccountRepoStub{}
  598. oauthService := &claudeOAuthServiceStub{
  599. tokenInfo: &TokenInfo{
  600. AccessToken: "new-access-token",
  601. RefreshToken: "new-refresh-token",
  602. TokenType: "Bearer",
  603. ExpiresIn: 3600,
  604. },
  605. }
  606. // Token expires soon
  607. expiresAt := time.Now().Add(1 * time.Minute).Format(time.RFC3339)
  608. account := &Account{
  609. ID: 115,
  610. Platform: PlatformAnthropic,
  611. Type: AccountTypeOAuth,
  612. Credentials: map[string]any{
  613. "access_token": "old-access-token",
  614. "refresh_token": "old-refresh-token",
  615. "expires_at": expiresAt,
  616. "custom_field": "should-be-preserved",
  617. "organization": "test-org",
  618. },
  619. }
  620. accountRepo.account = account
  621. provider := &testClaudeTokenProvider{
  622. accountRepo: accountRepo,
  623. tokenCache: cache,
  624. oauthService: oauthService,
  625. }
  626. token, err := provider.GetAccessToken(context.Background(), account)
  627. require.NoError(t, err)
  628. require.Equal(t, "new-access-token", token)
  629. // Verify existing fields are preserved
  630. require.Equal(t, "should-be-preserved", accountRepo.account.Credentials["custom_field"])
  631. require.Equal(t, "test-org", accountRepo.account.Credentials["organization"])
  632. // Verify new fields are updated
  633. require.Equal(t, "new-access-token", accountRepo.account.Credentials["access_token"])
  634. require.Equal(t, "new-refresh-token", accountRepo.account.Credentials["refresh_token"])
  635. }
  636. func TestClaudeTokenProvider_DoubleCheckCacheAfterLock(t *testing.T) {
  637. cache := newClaudeTokenCacheStub()
  638. accountRepo := &claudeAccountRepoStub{}
  639. oauthService := &claudeOAuthServiceStub{
  640. tokenInfo: &TokenInfo{
  641. AccessToken: "refreshed-token",
  642. RefreshToken: "new-refresh",
  643. TokenType: "Bearer",
  644. ExpiresIn: 3600,
  645. },
  646. }
  647. // Token expires soon
  648. expiresAt := time.Now().Add(1 * time.Minute).Format(time.RFC3339)
  649. account := &Account{
  650. ID: 116,
  651. Platform: PlatformAnthropic,
  652. Type: AccountTypeOAuth,
  653. Credentials: map[string]any{
  654. "access_token": "old-token",
  655. "expires_at": expiresAt,
  656. },
  657. }
  658. accountRepo.account = account
  659. cacheKey := ClaudeTokenCacheKey(account)
  660. // After lock is acquired, cache should have the token (simulating another worker)
  661. go func() {
  662. time.Sleep(5 * time.Millisecond)
  663. cache.mu.Lock()
  664. cache.tokens[cacheKey] = "cached-by-other-worker"
  665. cache.mu.Unlock()
  666. }()
  667. provider := &testClaudeTokenProvider{
  668. accountRepo: accountRepo,
  669. tokenCache: cache,
  670. oauthService: oauthService,
  671. }
  672. token, err := provider.GetAccessToken(context.Background(), account)
  673. require.NoError(t, err)
  674. require.NotEmpty(t, token)
  675. }
  676. // Tests for real provider - to increase coverage
  677. func TestClaudeTokenProvider_Real_LockFailedWait(t *testing.T) {
  678. cache := newClaudeTokenCacheStub()
  679. cache.lockAcquired = false // Lock acquisition fails
  680. // Token expires soon (within refresh skew) to trigger lock attempt
  681. expiresAt := time.Now().Add(1 * time.Minute).Format(time.RFC3339)
  682. account := &Account{
  683. ID: 300,
  684. Platform: PlatformAnthropic,
  685. Type: AccountTypeOAuth,
  686. Credentials: map[string]any{
  687. "access_token": "fallback-token",
  688. "expires_at": expiresAt,
  689. },
  690. }
  691. // Set token in cache after lock wait period (simulate other worker refreshing)
  692. cacheKey := ClaudeTokenCacheKey(account)
  693. go func() {
  694. time.Sleep(100 * time.Millisecond)
  695. cache.mu.Lock()
  696. cache.tokens[cacheKey] = "refreshed-by-other"
  697. cache.mu.Unlock()
  698. }()
  699. provider := NewClaudeTokenProvider(nil, cache, nil)
  700. token, err := provider.GetAccessToken(context.Background(), account)
  701. require.NoError(t, err)
  702. require.NotEmpty(t, token)
  703. }
  704. func TestClaudeTokenProvider_Real_CacheHitAfterWait(t *testing.T) {
  705. cache := newClaudeTokenCacheStub()
  706. cache.lockAcquired = false // Lock acquisition fails
  707. // Token expires soon
  708. expiresAt := time.Now().Add(1 * time.Minute).Format(time.RFC3339)
  709. account := &Account{
  710. ID: 301,
  711. Platform: PlatformAnthropic,
  712. Type: AccountTypeOAuth,
  713. Credentials: map[string]any{
  714. "access_token": "original-token",
  715. "expires_at": expiresAt,
  716. },
  717. }
  718. cacheKey := ClaudeTokenCacheKey(account)
  719. // Set token in cache immediately after wait starts
  720. go func() {
  721. time.Sleep(50 * time.Millisecond)
  722. cache.mu.Lock()
  723. cache.tokens[cacheKey] = "winner-token"
  724. cache.mu.Unlock()
  725. }()
  726. provider := NewClaudeTokenProvider(nil, cache, nil)
  727. token, err := provider.GetAccessToken(context.Background(), account)
  728. require.NoError(t, err)
  729. require.NotEmpty(t, token)
  730. }
  731. func TestClaudeTokenProvider_Real_NoExpiresAt(t *testing.T) {
  732. cache := newClaudeTokenCacheStub()
  733. cache.lockAcquired = false // Prevent entering refresh logic
  734. // Token with nil expires_at (no expiry set)
  735. account := &Account{
  736. ID: 302,
  737. Platform: PlatformAnthropic,
  738. Type: AccountTypeOAuth,
  739. Credentials: map[string]any{
  740. "access_token": "no-expiry-token",
  741. },
  742. }
  743. // After lock wait, return token from credentials
  744. provider := NewClaudeTokenProvider(nil, cache, nil)
  745. token, err := provider.GetAccessToken(context.Background(), account)
  746. require.NoError(t, err)
  747. require.Equal(t, "no-expiry-token", token)
  748. }
  749. func TestClaudeTokenProvider_Real_WhitespaceToken(t *testing.T) {
  750. cache := newClaudeTokenCacheStub()
  751. cacheKey := "claude:account:303"
  752. cache.tokens[cacheKey] = " " // Whitespace only - should be treated as empty
  753. expiresAt := time.Now().Add(1 * time.Hour).Format(time.RFC3339)
  754. account := &Account{
  755. ID: 303,
  756. Platform: PlatformAnthropic,
  757. Type: AccountTypeOAuth,
  758. Credentials: map[string]any{
  759. "access_token": "real-token",
  760. "expires_at": expiresAt,
  761. },
  762. }
  763. provider := NewClaudeTokenProvider(nil, cache, nil)
  764. token, err := provider.GetAccessToken(context.Background(), account)
  765. require.NoError(t, err)
  766. require.Equal(t, "real-token", token)
  767. }
  768. func TestClaudeTokenProvider_Real_EmptyCredentialToken(t *testing.T) {
  769. cache := newClaudeTokenCacheStub()
  770. expiresAt := time.Now().Add(1 * time.Hour).Format(time.RFC3339)
  771. account := &Account{
  772. ID: 304,
  773. Platform: PlatformAnthropic,
  774. Type: AccountTypeOAuth,
  775. Credentials: map[string]any{
  776. "access_token": " ", // Whitespace only
  777. "expires_at": expiresAt,
  778. },
  779. }
  780. provider := NewClaudeTokenProvider(nil, cache, nil)
  781. token, err := provider.GetAccessToken(context.Background(), account)
  782. require.Error(t, err)
  783. require.Contains(t, err.Error(), "access_token not found")
  784. require.Empty(t, token)
  785. }
  786. func TestClaudeTokenProvider_Real_LockError(t *testing.T) {
  787. cache := newClaudeTokenCacheStub()
  788. cache.lockErr = errors.New("redis lock failed")
  789. // Token expires soon (within refresh skew)
  790. expiresAt := time.Now().Add(1 * time.Minute).Format(time.RFC3339)
  791. account := &Account{
  792. ID: 305,
  793. Platform: PlatformAnthropic,
  794. Type: AccountTypeOAuth,
  795. Credentials: map[string]any{
  796. "access_token": "fallback-on-lock-error",
  797. "expires_at": expiresAt,
  798. },
  799. }
  800. provider := NewClaudeTokenProvider(nil, cache, nil)
  801. token, err := provider.GetAccessToken(context.Background(), account)
  802. require.NoError(t, err)
  803. require.Equal(t, "fallback-on-lock-error", token)
  804. }
  805. func TestClaudeTokenProvider_Real_NilCredentials(t *testing.T) {
  806. cache := newClaudeTokenCacheStub()
  807. expiresAt := time.Now().Add(1 * time.Hour).Format(time.RFC3339)
  808. account := &Account{
  809. ID: 306,
  810. Platform: PlatformAnthropic,
  811. Type: AccountTypeOAuth,
  812. Credentials: map[string]any{
  813. "expires_at": expiresAt,
  814. // No access_token
  815. },
  816. }
  817. provider := NewClaudeTokenProvider(nil, cache, nil)
  818. token, err := provider.GetAccessToken(context.Background(), account)
  819. require.Error(t, err)
  820. require.Contains(t, err.Error(), "access_token not found")
  821. require.Empty(t, token)
  822. }