openai_oauth_service_refresh_test.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "net/http"
  6. "net/http/httptest"
  7. "sync/atomic"
  8. "testing"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  11. "github.com/imroc/req/v3"
  12. "github.com/stretchr/testify/require"
  13. )
  14. type openaiOAuthClientRefreshStub struct {
  15. refreshCalls int32
  16. }
  17. func (s *openaiOAuthClientRefreshStub) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*openai.TokenResponse, error) {
  18. return nil, errors.New("not implemented")
  19. }
  20. func (s *openaiOAuthClientRefreshStub) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*openai.TokenResponse, error) {
  21. atomic.AddInt32(&s.refreshCalls, 1)
  22. return nil, errors.New("not implemented")
  23. }
  24. func (s *openaiOAuthClientRefreshStub) RefreshTokenWithClientID(ctx context.Context, refreshToken, proxyURL string, clientID string) (*openai.TokenResponse, error) {
  25. atomic.AddInt32(&s.refreshCalls, 1)
  26. return nil, errors.New("not implemented")
  27. }
  28. func TestOpenAIOAuthService_RefreshAccountToken_NoRefreshTokenUsesExistingAccessToken(t *testing.T) {
  29. client := &openaiOAuthClientRefreshStub{}
  30. svc := NewOpenAIOAuthService(nil, client)
  31. var privacyClientCalls int32
  32. svc.SetPrivacyClientFactory(func(proxyURL string) (*req.Client, error) {
  33. atomic.AddInt32(&privacyClientCalls, 1)
  34. return nil, errors.New("stop before request")
  35. })
  36. expiresAt := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
  37. account := &Account{
  38. ID: 77,
  39. Platform: PlatformOpenAI,
  40. Type: AccountTypeOAuth,
  41. Credentials: map[string]any{
  42. "access_token": "existing-access-token",
  43. "expires_at": expiresAt,
  44. "client_id": "client-id-1",
  45. },
  46. }
  47. info, err := svc.RefreshAccountToken(context.Background(), account)
  48. require.NoError(t, err)
  49. require.NotNil(t, info)
  50. require.Equal(t, "existing-access-token", info.AccessToken)
  51. require.Equal(t, "client-id-1", info.ClientID)
  52. require.Zero(t, atomic.LoadInt32(&client.refreshCalls), "existing access token should be reused without calling refresh")
  53. require.Positive(t, atomic.LoadInt32(&privacyClientCalls), "existing access token should still run enrichment")
  54. }
  55. func TestOpenAIOAuthService_RefreshAccountToken_PATIgnoresStaleRefreshToken(t *testing.T) {
  56. client := &openaiOAuthClientRefreshStub{}
  57. var whoamiCalls int32
  58. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  59. atomic.AddInt32(&whoamiCalls, 1)
  60. w.Header().Set("content-type", "application/json")
  61. _, _ = w.Write([]byte(`{
  62. "email":"user@example.com",
  63. "chatgpt_user_id":"user-123",
  64. "chatgpt_account_id":"acct-123",
  65. "chatgpt_plan_type":"plus",
  66. "chatgpt_account_is_fedramp":false
  67. }`))
  68. }))
  69. defer server.Close()
  70. originalURL := openAICodexPATWhoamiURL
  71. openAICodexPATWhoamiURL = server.URL
  72. defer func() { openAICodexPATWhoamiURL = originalURL }()
  73. svc := NewOpenAIOAuthService(nil, client)
  74. defer svc.Stop()
  75. account := &Account{
  76. ID: 77,
  77. Platform: PlatformOpenAI,
  78. Type: AccountTypeOAuth,
  79. Credentials: map[string]any{
  80. "access_token": "at-test-token",
  81. "refresh_token": "stale-refresh-token",
  82. "expires_at": time.Now().Add(-time.Hour).UTC().Format(time.RFC3339),
  83. "auth_mode": "personal_access_token",
  84. },
  85. }
  86. info, err := svc.RefreshAccountToken(context.Background(), account)
  87. require.NoError(t, err)
  88. require.Equal(t, OpenAIAuthModePersonalAccessToken, info.AuthMode)
  89. require.Equal(t, "at-test-token", info.AccessToken)
  90. require.Empty(t, info.RefreshToken)
  91. require.Equal(t, int32(1), atomic.LoadInt32(&whoamiCalls))
  92. require.Zero(t, atomic.LoadInt32(&client.refreshCalls), "PAT accounts must not call OAuth refresh even if stale refresh_token remains")
  93. }
  94. func TestOpenAITokenRefresher_NeedsRefresh_SkipsAccountWithoutRefreshToken(t *testing.T) {
  95. refresher := NewOpenAITokenRefresher(nil, nil)
  96. expiresAt := time.Now().Add(time.Minute).UTC().Format(time.RFC3339)
  97. withoutRT := &Account{
  98. Platform: PlatformOpenAI,
  99. Type: AccountTypeOAuth,
  100. Credentials: map[string]any{
  101. "access_token": "access-token",
  102. "expires_at": expiresAt,
  103. },
  104. }
  105. require.False(t, refresher.NeedsRefresh(withoutRT, 5*time.Minute))
  106. withRT := &Account{
  107. Platform: PlatformOpenAI,
  108. Type: AccountTypeOAuth,
  109. Credentials: map[string]any{
  110. "access_token": "access-token",
  111. "refresh_token": "refresh-token",
  112. "expires_at": expiresAt,
  113. },
  114. }
  115. require.True(t, refresher.NeedsRefresh(withRT, 5*time.Minute))
  116. patWithStaleRT := &Account{
  117. Platform: PlatformOpenAI,
  118. Type: AccountTypeOAuth,
  119. Credentials: map[string]any{
  120. "access_token": "at-test-token",
  121. "refresh_token": "stale-refresh-token",
  122. "expires_at": expiresAt,
  123. "auth_mode": OpenAIAuthModePersonalAccessToken,
  124. },
  125. }
  126. require.False(t, refresher.NeedsRefresh(patWithStaleRT, 5*time.Minute))
  127. }
  128. func TestOpenAITokenRefresher_Refresh_PATRemovesStaleOAuthFields(t *testing.T) {
  129. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  130. w.Header().Set("content-type", "application/json")
  131. _, _ = w.Write([]byte(`{
  132. "email":"user@example.com",
  133. "chatgpt_user_id":"user-123",
  134. "chatgpt_account_id":"acct-123",
  135. "chatgpt_plan_type":"plus",
  136. "chatgpt_account_is_fedramp":true
  137. }`))
  138. }))
  139. defer server.Close()
  140. originalURL := openAICodexPATWhoamiURL
  141. openAICodexPATWhoamiURL = server.URL
  142. defer func() { openAICodexPATWhoamiURL = originalURL }()
  143. svc := NewOpenAIOAuthService(nil, nil)
  144. defer svc.Stop()
  145. refresher := NewOpenAITokenRefresher(svc, nil)
  146. account := &Account{
  147. Platform: PlatformOpenAI,
  148. Type: AccountTypeOAuth,
  149. Credentials: map[string]any{
  150. "access_token": "at-test-token",
  151. "refresh_token": "stale-refresh-token",
  152. "id_token": "stale-id-token",
  153. "expires_at": time.Now().Add(-time.Hour).UTC().Format(time.RFC3339),
  154. "expires_in": 3600,
  155. "client_id": "stale-client",
  156. "auth_mode": OpenAIAuthModePersonalAccessToken,
  157. "model_mapping": map[string]any{"gpt-5": "gpt-5-codex"},
  158. },
  159. }
  160. credentials, err := refresher.Refresh(context.Background(), account)
  161. require.NoError(t, err)
  162. require.Equal(t, "at-test-token", credentials["access_token"])
  163. require.Equal(t, OpenAIAuthModePersonalAccessToken, credentials["auth_mode"])
  164. require.Equal(t, "personal_access_token", credentials["openai_auth_mode"])
  165. require.NotContains(t, credentials, "refresh_token")
  166. require.NotContains(t, credentials, "id_token")
  167. require.NotContains(t, credentials, "expires_at")
  168. require.NotContains(t, credentials, "expires_in")
  169. require.NotContains(t, credentials, "client_id")
  170. require.Equal(t, map[string]any{"gpt-5": "gpt-5-codex"}, credentials["model_mapping"])
  171. }
  172. func TestOpenAITokenProvider_NoRefreshTokenExpiredAccessTokenReturnsError(t *testing.T) {
  173. provider := NewOpenAITokenProvider(nil, nil, nil)
  174. expiresAt := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
  175. account := &Account{
  176. Platform: PlatformOpenAI,
  177. Type: AccountTypeOAuth,
  178. Credentials: map[string]any{
  179. "access_token": "expired-access-token",
  180. "expires_at": expiresAt,
  181. },
  182. }
  183. token, err := provider.GetAccessToken(context.Background(), account)
  184. require.Error(t, err)
  185. require.Empty(t, token)
  186. require.Contains(t, err.Error(), "refresh_token is missing")
  187. }