| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212 |
- package service
- import (
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
- "github.com/imroc/req/v3"
- "github.com/stretchr/testify/require"
- )
- type openaiOAuthClientRefreshStub struct {
- refreshCalls int32
- }
- func (s *openaiOAuthClientRefreshStub) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*openai.TokenResponse, error) {
- return nil, errors.New("not implemented")
- }
- func (s *openaiOAuthClientRefreshStub) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*openai.TokenResponse, error) {
- atomic.AddInt32(&s.refreshCalls, 1)
- return nil, errors.New("not implemented")
- }
- func (s *openaiOAuthClientRefreshStub) RefreshTokenWithClientID(ctx context.Context, refreshToken, proxyURL string, clientID string) (*openai.TokenResponse, error) {
- atomic.AddInt32(&s.refreshCalls, 1)
- return nil, errors.New("not implemented")
- }
- func TestOpenAIOAuthService_RefreshAccountToken_NoRefreshTokenUsesExistingAccessToken(t *testing.T) {
- client := &openaiOAuthClientRefreshStub{}
- svc := NewOpenAIOAuthService(nil, client)
- var privacyClientCalls int32
- svc.SetPrivacyClientFactory(func(proxyURL string) (*req.Client, error) {
- atomic.AddInt32(&privacyClientCalls, 1)
- return nil, errors.New("stop before request")
- })
- expiresAt := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
- account := &Account{
- ID: 77,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "existing-access-token",
- "expires_at": expiresAt,
- "client_id": "client-id-1",
- },
- }
- info, err := svc.RefreshAccountToken(context.Background(), account)
- require.NoError(t, err)
- require.NotNil(t, info)
- require.Equal(t, "existing-access-token", info.AccessToken)
- require.Equal(t, "client-id-1", info.ClientID)
- require.Zero(t, atomic.LoadInt32(&client.refreshCalls), "existing access token should be reused without calling refresh")
- require.Positive(t, atomic.LoadInt32(&privacyClientCalls), "existing access token should still run enrichment")
- }
- func TestOpenAIOAuthService_RefreshAccountToken_PATIgnoresStaleRefreshToken(t *testing.T) {
- client := &openaiOAuthClientRefreshStub{}
- var whoamiCalls int32
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- atomic.AddInt32(&whoamiCalls, 1)
- w.Header().Set("content-type", "application/json")
- _, _ = w.Write([]byte(`{
- "email":"user@example.com",
- "chatgpt_user_id":"user-123",
- "chatgpt_account_id":"acct-123",
- "chatgpt_plan_type":"plus",
- "chatgpt_account_is_fedramp":false
- }`))
- }))
- defer server.Close()
- originalURL := openAICodexPATWhoamiURL
- openAICodexPATWhoamiURL = server.URL
- defer func() { openAICodexPATWhoamiURL = originalURL }()
- svc := NewOpenAIOAuthService(nil, client)
- defer svc.Stop()
- account := &Account{
- ID: 77,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "at-test-token",
- "refresh_token": "stale-refresh-token",
- "expires_at": time.Now().Add(-time.Hour).UTC().Format(time.RFC3339),
- "auth_mode": "personal_access_token",
- },
- }
- info, err := svc.RefreshAccountToken(context.Background(), account)
- require.NoError(t, err)
- require.Equal(t, OpenAIAuthModePersonalAccessToken, info.AuthMode)
- require.Equal(t, "at-test-token", info.AccessToken)
- require.Empty(t, info.RefreshToken)
- require.Equal(t, int32(1), atomic.LoadInt32(&whoamiCalls))
- require.Zero(t, atomic.LoadInt32(&client.refreshCalls), "PAT accounts must not call OAuth refresh even if stale refresh_token remains")
- }
- func TestOpenAITokenRefresher_NeedsRefresh_SkipsAccountWithoutRefreshToken(t *testing.T) {
- refresher := NewOpenAITokenRefresher(nil, nil)
- expiresAt := time.Now().Add(time.Minute).UTC().Format(time.RFC3339)
- withoutRT := &Account{
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "access-token",
- "expires_at": expiresAt,
- },
- }
- require.False(t, refresher.NeedsRefresh(withoutRT, 5*time.Minute))
- withRT := &Account{
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "access-token",
- "refresh_token": "refresh-token",
- "expires_at": expiresAt,
- },
- }
- require.True(t, refresher.NeedsRefresh(withRT, 5*time.Minute))
- patWithStaleRT := &Account{
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "at-test-token",
- "refresh_token": "stale-refresh-token",
- "expires_at": expiresAt,
- "auth_mode": OpenAIAuthModePersonalAccessToken,
- },
- }
- require.False(t, refresher.NeedsRefresh(patWithStaleRT, 5*time.Minute))
- }
- func TestOpenAITokenRefresher_Refresh_PATRemovesStaleOAuthFields(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("content-type", "application/json")
- _, _ = w.Write([]byte(`{
- "email":"user@example.com",
- "chatgpt_user_id":"user-123",
- "chatgpt_account_id":"acct-123",
- "chatgpt_plan_type":"plus",
- "chatgpt_account_is_fedramp":true
- }`))
- }))
- defer server.Close()
- originalURL := openAICodexPATWhoamiURL
- openAICodexPATWhoamiURL = server.URL
- defer func() { openAICodexPATWhoamiURL = originalURL }()
- svc := NewOpenAIOAuthService(nil, nil)
- defer svc.Stop()
- refresher := NewOpenAITokenRefresher(svc, nil)
- account := &Account{
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "at-test-token",
- "refresh_token": "stale-refresh-token",
- "id_token": "stale-id-token",
- "expires_at": time.Now().Add(-time.Hour).UTC().Format(time.RFC3339),
- "expires_in": 3600,
- "client_id": "stale-client",
- "auth_mode": OpenAIAuthModePersonalAccessToken,
- "model_mapping": map[string]any{"gpt-5": "gpt-5-codex"},
- },
- }
- credentials, err := refresher.Refresh(context.Background(), account)
- require.NoError(t, err)
- require.Equal(t, "at-test-token", credentials["access_token"])
- require.Equal(t, OpenAIAuthModePersonalAccessToken, credentials["auth_mode"])
- require.Equal(t, "personal_access_token", credentials["openai_auth_mode"])
- require.NotContains(t, credentials, "refresh_token")
- require.NotContains(t, credentials, "id_token")
- require.NotContains(t, credentials, "expires_at")
- require.NotContains(t, credentials, "expires_in")
- require.NotContains(t, credentials, "client_id")
- require.Equal(t, map[string]any{"gpt-5": "gpt-5-codex"}, credentials["model_mapping"])
- }
- func TestOpenAITokenProvider_NoRefreshTokenExpiredAccessTokenReturnsError(t *testing.T) {
- provider := NewOpenAITokenProvider(nil, nil, nil)
- expiresAt := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339)
- account := &Account{
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "expired-access-token",
- "expires_at": expiresAt,
- },
- }
- token, err := provider.GetAccessToken(context.Background(), account)
- require.Error(t, err)
- require.Empty(t, token)
- require.Contains(t, err.Error(), "refresh_token is missing")
- }
|