| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336 |
- //go:build unit
- package service
- import (
- "context"
- "errors"
- "net/http"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/stretchr/testify/require"
- )
- type rateLimitAccountRepoStub struct {
- mockAccountRepoForGemini
- setErrorCalls int
- tempCalls int
- updateCredentialsCalls int
- updateExtraCalls int
- lastCredentials map[string]any
- lastExtraUpdates map[string]any
- lastErrorMsg string
- lastTempReason string
- lastErrorID int64
- lastTempID int64
- tempErr error
- }
- func (r *rateLimitAccountRepoStub) SetError(ctx context.Context, id int64, errorMsg string) error {
- r.setErrorCalls++
- r.lastErrorID = id
- r.lastErrorMsg = errorMsg
- return nil
- }
- func (r *rateLimitAccountRepoStub) SetTempUnschedulable(ctx context.Context, id int64, until time.Time, reason string) error {
- r.tempCalls++
- r.lastTempID = id
- r.lastTempReason = reason
- return r.tempErr
- }
- func (r *rateLimitAccountRepoStub) UpdateCredentials(ctx context.Context, id int64, credentials map[string]any) error {
- r.updateCredentialsCalls++
- r.lastCredentials = shallowCopyMap(credentials)
- return nil
- }
- func (r *rateLimitAccountRepoStub) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error {
- r.updateExtraCalls++
- r.lastExtraUpdates = shallowCopyMap(updates)
- return nil
- }
- type tokenCacheInvalidatorRecorder struct {
- accounts []*Account
- err error
- }
- type openAI403CounterCacheStub struct {
- counts []int64
- resetCalls []int64
- err error
- }
- func (s *openAI403CounterCacheStub) IncrementOpenAI403Count(_ context.Context, _ int64, _ int) (int64, error) {
- if s.err != nil {
- return 0, s.err
- }
- if len(s.counts) == 0 {
- return 1, nil
- }
- count := s.counts[0]
- s.counts = s.counts[1:]
- return count, nil
- }
- func (s *openAI403CounterCacheStub) ResetOpenAI403Count(_ context.Context, accountID int64) error {
- s.resetCalls = append(s.resetCalls, accountID)
- return nil
- }
- func (r *tokenCacheInvalidatorRecorder) InvalidateToken(ctx context.Context, account *Account) error {
- r.accounts = append(r.accounts, account)
- return r.err
- }
- func TestRateLimitService_HandleUpstreamError_OAuth401SetsTempUnschedulable(t *testing.T) {
- t.Run("gemini", func(t *testing.T) {
- repo := &rateLimitAccountRepoStub{}
- invalidator := &tokenCacheInvalidatorRecorder{}
- service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
- service.SetTokenCacheInvalidator(invalidator)
- account := &Account{
- ID: 100,
- Platform: PlatformGemini,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "refresh_token": "rt-100",
- "temp_unschedulable_enabled": true,
- "temp_unschedulable_rules": []any{
- map[string]any{
- "error_code": 401,
- "keywords": []any{"unauthorized"},
- "duration_minutes": 30,
- "description": "custom rule",
- },
- },
- },
- }
- shouldDisable := service.HandleUpstreamError(context.Background(), account, 401, http.Header{}, []byte("unauthorized"))
- require.True(t, shouldDisable)
- require.Equal(t, 0, repo.setErrorCalls)
- require.Equal(t, 1, repo.tempCalls)
- require.Len(t, invalidator.accounts, 1)
- })
- t.Run("antigravity_401_sets_temp_unschedulable", func(t *testing.T) {
- repo := &rateLimitAccountRepoStub{}
- invalidator := &tokenCacheInvalidatorRecorder{}
- service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
- service.SetTokenCacheInvalidator(invalidator)
- account := &Account{
- ID: 100,
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Credentials: map[string]any{
- "access_token": "expired-at",
- "refresh_token": "rt-100",
- },
- }
- shouldDisable := service.HandleUpstreamError(context.Background(), account, 401, http.Header{}, []byte("unauthorized"))
- require.True(t, shouldDisable)
- require.Equal(t, 0, repo.setErrorCalls, "Antigravity OAuth 401 must keep status=active so refresh worker can recover it")
- require.Equal(t, 1, repo.tempCalls)
- require.Equal(t, int64(100), repo.lastTempID)
- require.Contains(t, repo.lastTempReason, "invalid or expired credentials")
- require.Equal(t, 1, repo.updateExtraCalls)
- require.Equal(t, true, repo.lastExtraUpdates[antigravityForceTokenRefreshExtraKey])
- require.Equal(t, "401_invalid", repo.lastExtraUpdates[antigravityForceTokenRefreshReasonExtraKey])
- require.Equal(t, true, account.Extra[antigravityForceTokenRefreshExtraKey])
- require.Len(t, invalidator.accounts, 1)
- require.Equal(t, int64(100), invalidator.accounts[0].ID)
- })
- }
- // TestRateLimitService_HandleUpstreamError_SparkShadow401RedirectsToParent 外审第9轮:影子无独立凭据,
- // 401(母账号 token 问题)必须重定向到凭据 owner(母账号)——母账号 temp-unschedulable + token cache 失效,
- // 影子不得被永久禁用(否则母账号可恢复的 token 问题会把影子永久打死)。
- func TestRateLimitService_HandleUpstreamError_SparkShadow401RedirectsToParent(t *testing.T) {
- repo := &rateLimitAccountRepoStub{}
- repo.accountsByID = map[int64]*Account{}
- invalidator := &tokenCacheInvalidatorRecorder{}
- service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
- service.SetTokenCacheInvalidator(invalidator)
- const parentID = int64(500)
- mother := &Account{
- ID: parentID,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{"refresh_token": "rt-mother"},
- }
- repo.accountsByID[parentID] = mother
- shadowParent := parentID
- shadow := &Account{
- ID: 501,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- ParentAccountID: &shadowParent,
- QuotaDimension: QuotaDimensionSpark,
- // 影子不持凭据:GetCredential("refresh_token") == ""
- }
- shouldDisable := service.HandleUpstreamError(context.Background(), shadow, 401, http.Header{}, []byte("unauthorized"))
- require.True(t, shouldDisable)
- require.Equal(t, 0, repo.setErrorCalls, "spark shadow must not be permanently disabled on a parent-token 401")
- require.Equal(t, 1, repo.tempCalls)
- require.Equal(t, parentID, repo.lastTempID, "temp-unschedulable must target the credential owner (parent)")
- require.Len(t, invalidator.accounts, 1)
- require.Equal(t, parentID, invalidator.accounts[0].ID, "token cache invalidation must target the parent")
- }
- // TestRateLimitService_HandleUpstreamError_OAuth401InvalidatorError
- // OpenAI OAuth 401 缓存失效出错时仍走 temp_unschedulable。
- // 注意:401 handler 不再回写 credentials(避免请求开始时的快照整列覆盖 DB
- // 把另一个 worker 刚刷新出来的新 refresh_token 回滚为旧值),
- // 因此 updateCredentialsCalls 应当为 0。
- func TestRateLimitService_HandleUpstreamError_OAuth401InvalidatorError(t *testing.T) {
- repo := &rateLimitAccountRepoStub{}
- invalidator := &tokenCacheInvalidatorRecorder{err: errors.New("boom")}
- service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
- service.SetTokenCacheInvalidator(invalidator)
- account := &Account{
- ID: 101,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "refresh_token": "rt-101",
- },
- }
- shouldDisable := service.HandleUpstreamError(context.Background(), account, 401, http.Header{}, []byte("unauthorized"))
- require.True(t, shouldDisable)
- require.Equal(t, 0, repo.setErrorCalls)
- require.Equal(t, 1, repo.tempCalls)
- require.Equal(t, 0, repo.updateCredentialsCalls)
- require.Len(t, invalidator.accounts, 1)
- }
- func TestRateLimitService_HandleUpstreamError_NonOAuth401(t *testing.T) {
- repo := &rateLimitAccountRepoStub{}
- invalidator := &tokenCacheInvalidatorRecorder{}
- service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
- service.SetTokenCacheInvalidator(invalidator)
- account := &Account{
- ID: 102,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- }
- shouldDisable := service.HandleUpstreamError(context.Background(), account, 401, http.Header{}, []byte("unauthorized"))
- require.True(t, shouldDisable)
- require.Equal(t, 1, repo.setErrorCalls)
- require.Empty(t, invalidator.accounts)
- }
- // TestRateLimitService_HandleUpstreamError_OAuth401DoesNotOverwriteCredentials
- // 回归测试:确保 401 handler 不再使用请求开始时的 account 快照写回 credentials。
- // 原实现会通过 persistAccountCredentials → UpdateCredentials → SetCredentials
- // 整列覆盖 credentials JSONB,在另一个 worker 刚刷新完 refresh_token 的窄窗口内
- // 会把新 refresh_token 回滚为快照中的旧值,导致下一周期拿 invalid_grant 被错误 disable。
- func TestRateLimitService_HandleUpstreamError_OAuth401DoesNotOverwriteCredentials(t *testing.T) {
- repo := &rateLimitAccountRepoStub{}
- service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
- account := &Account{
- ID: 103,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "token",
- "refresh_token": "rt-103",
- },
- }
- shouldDisable := service.HandleUpstreamError(context.Background(), account, 401, http.Header{}, []byte("unauthorized"))
- require.True(t, shouldDisable)
- require.Equal(t, 0, repo.updateCredentialsCalls, "401 handler must not write credentials back from the request-start snapshot")
- require.Equal(t, 0, repo.updateExtraCalls, "OpenAI 401 must not set Antigravity force-refresh marker")
- require.Equal(t, 1, repo.tempCalls, "401 handler should still set temp-unschedulable cooldown")
- require.Nil(t, repo.lastCredentials, "no credentials should have been persisted")
- }
- // 缺少 refresh_token 的 OAuth 账号 401 应直接 SetError 永久禁用,
- // 不再走 10 分钟冷却(冷却期内无人能刷新它,结束后还会被选中再 502 一次)。
- func TestRateLimitService_HandleUpstreamError_OAuth401NoRefreshTokenSetsError(t *testing.T) {
- t.Run("openai_no_refresh_token", func(t *testing.T) {
- repo := &rateLimitAccountRepoStub{}
- invalidator := &tokenCacheInvalidatorRecorder{}
- service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
- service.SetTokenCacheInvalidator(invalidator)
- account := &Account{
- ID: 2881,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "expired-at",
- // no refresh_token
- },
- }
- shouldDisable := service.HandleUpstreamError(context.Background(), account, 401, http.Header{}, []byte("unauthorized"))
- require.True(t, shouldDisable)
- require.Equal(t, 1, repo.setErrorCalls, "AT-only OAuth 401 must SetError")
- require.Equal(t, 0, repo.tempCalls, "AT-only OAuth 401 must NOT temp-unschedule")
- require.Equal(t, 0, repo.updateCredentialsCalls, "no point forcing expires_at when refresh is impossible")
- require.Contains(t, repo.lastErrorMsg, "refresh_token missing")
- require.Len(t, invalidator.accounts, 1, "cache should still be invalidated")
- })
- t.Run("openai_blank_refresh_token_treated_as_missing", func(t *testing.T) {
- repo := &rateLimitAccountRepoStub{}
- service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
- account := &Account{
- ID: 2882,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "expired-at",
- "refresh_token": " ",
- },
- }
- shouldDisable := service.HandleUpstreamError(context.Background(), account, 401, http.Header{}, []byte("unauthorized"))
- require.True(t, shouldDisable)
- require.Equal(t, 1, repo.setErrorCalls)
- require.Equal(t, 0, repo.tempCalls)
- })
- t.Run("antigravity_no_refresh_token_sets_error", func(t *testing.T) {
- repo := &rateLimitAccountRepoStub{}
- invalidator := &tokenCacheInvalidatorRecorder{}
- service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
- service.SetTokenCacheInvalidator(invalidator)
- account := &Account{
- ID: 2883,
- Platform: PlatformAntigravity,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "expired-at",
- },
- }
- shouldDisable := service.HandleUpstreamError(context.Background(), account, 401, http.Header{}, []byte("unauthorized"))
- require.True(t, shouldDisable)
- require.Equal(t, 1, repo.setErrorCalls, "Antigravity OAuth without refresh_token cannot self-recover")
- require.Equal(t, 0, repo.tempCalls)
- require.Contains(t, repo.lastErrorMsg, "refresh_token missing")
- require.Len(t, invalidator.accounts, 1)
- })
- }
|