Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user