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,326 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type duplicateAccountRepoStub struct {
|
||||
*sparkShadowRepoStub
|
||||
atomicCreateErr error
|
||||
accountGroupsOf map[int64][]AccountGroup
|
||||
}
|
||||
|
||||
func newDuplicateAccountRepoStub() *duplicateAccountRepoStub {
|
||||
return &duplicateAccountRepoStub{
|
||||
sparkShadowRepoStub: newSparkShadowRepoStub(),
|
||||
accountGroupsOf: make(map[int64][]AccountGroup),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *duplicateAccountRepoStub) CreateWithAccountGroups(ctx context.Context, account *Account, groups []AccountGroup) error {
|
||||
if s.atomicCreateErr != nil {
|
||||
return s.atomicCreateErr
|
||||
}
|
||||
groupIDs := make([]int64, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
groupIDs = append(groupIDs, group.GroupID)
|
||||
}
|
||||
account.GroupIDs = groupIDs
|
||||
if err := s.Create(ctx, account); err != nil {
|
||||
return err
|
||||
}
|
||||
clonedGroups := make([]AccountGroup, len(groups))
|
||||
copy(clonedGroups, groups)
|
||||
for i := range clonedGroups {
|
||||
clonedGroups[i].AccountID = account.ID
|
||||
}
|
||||
account.AccountGroups = clonedGroups
|
||||
s.accountGroupsOf[account.ID] = clonedGroups
|
||||
if len(groupIDs) > 0 {
|
||||
s.groupsOf[account.ID] = append([]int64(nil), groupIDs...)
|
||||
}
|
||||
stored := *account
|
||||
s.accounts[account.ID] = &stored
|
||||
s.mockAccountRepoForGemini.accountsByID[account.ID] = &stored
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *duplicateAccountRepoStub) FindByExtraField(_ context.Context, key string, value any) ([]Account, error) {
|
||||
wanted, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
var matches []Account
|
||||
for _, account := range s.accounts {
|
||||
if actual, ok := account.Extra[key].(string); ok && actual == wanted {
|
||||
matches = append(matches, *account)
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func TestDuplicateAccountCopiesConfigurationAndResetsRuntimeState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDuplicateAccountRepoStub()
|
||||
svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo}
|
||||
|
||||
notes := "keep this note"
|
||||
proxyID := int64(17)
|
||||
originalProxyID := int64(11)
|
||||
rateMultiplier := 1.25
|
||||
loadFactor := 9
|
||||
expiresAt := time.Date(2027, time.March, 4, 5, 6, 7, 0, time.UTC)
|
||||
rateLimitedAt := time.Now().Add(-time.Minute)
|
||||
rateLimitResetAt := time.Now().Add(time.Hour)
|
||||
overloadUntil := time.Now().Add(2 * time.Hour)
|
||||
tempUnschedulableUntil := time.Now().Add(3 * time.Hour)
|
||||
sessionWindowStart := time.Now().Add(-2 * time.Hour)
|
||||
sessionWindowEnd := time.Now().Add(2 * time.Hour)
|
||||
|
||||
source := &Account{
|
||||
Name: "primary",
|
||||
Notes: ¬es,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
ProxyID: &proxyID,
|
||||
ProxyFallbackOriginID: &originalProxyID,
|
||||
Concurrency: 6,
|
||||
Priority: 40,
|
||||
RateMultiplier: &rateMultiplier,
|
||||
LoadFactor: &loadFactor,
|
||||
Status: StatusError,
|
||||
Schedulable: true,
|
||||
ErrorMessage: "upstream unavailable",
|
||||
ExpiresAt: &expiresAt,
|
||||
AutoPauseOnExpired: false,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "secret",
|
||||
"nested": map[string]any{"token": "source-token"},
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"config": map[string]any{"region": "us-east-1"},
|
||||
"items": []any{map[string]any{"enabled": true}},
|
||||
"quota_limit": 1000,
|
||||
"quota_used": 450,
|
||||
"quota_daily_used": 25,
|
||||
"quota_daily_start": "2026-07-15T00:00:00Z",
|
||||
"model_rate_limits": map[string]any{"gpt-5": "2099-01-01T00:00:00Z"},
|
||||
"codex_5h_used_percent": 80,
|
||||
"codex_cli_only": true,
|
||||
"grok_usage_snapshot": map[string]any{"status_code": 429},
|
||||
"openai_responses_supported": false,
|
||||
"openai_compact_checked_at": "2026-07-15T00:00:00Z",
|
||||
"session_window_utilization": 0.8,
|
||||
"passive_usage_sampled_at": "2026-07-15T00:00:00Z",
|
||||
"antigravity_force_token_refresh": true,
|
||||
"antigravity_credits_overages": map[string]any{"enabled": true},
|
||||
"crs_account_id": "remote-42",
|
||||
"crs_kind": "openai-api-key",
|
||||
"crs_synced_at": "2026-07-15T00:00:00Z",
|
||||
},
|
||||
GroupIDs: []int64{7, 3},
|
||||
AccountGroups: []AccountGroup{{GroupID: 7, Priority: 50}, {GroupID: 3, Priority: 7}},
|
||||
RateLimitedAt: &rateLimitedAt,
|
||||
RateLimitResetAt: &rateLimitResetAt,
|
||||
OverloadUntil: &overloadUntil,
|
||||
TempUnschedulableUntil: &tempUnschedulableUntil,
|
||||
TempUnschedulableReason: "maintenance",
|
||||
SessionWindowStart: &sessionWindowStart,
|
||||
SessionWindowEnd: &sessionWindowEnd,
|
||||
SessionWindowStatus: "active",
|
||||
}
|
||||
source.Extra[UpstreamBillingProbeEnabledExtraKey] = true
|
||||
source.Extra[UpstreamBillingRateSyncEnabledExtraKey] = true
|
||||
source.Extra[UpstreamBillingProbeExtraKey] = map[string]any{"status": "ok"}
|
||||
require.NoError(t, repo.Create(ctx, source))
|
||||
|
||||
duplicate, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, source.ID, duplicate.ID)
|
||||
require.Equal(t, "primary (Copy)", duplicate.Name)
|
||||
require.Equal(t, source.Platform, duplicate.Platform)
|
||||
require.Equal(t, source.Type, duplicate.Type)
|
||||
require.Equal(t, source.Concurrency, duplicate.Concurrency)
|
||||
require.Equal(t, source.Priority, duplicate.Priority)
|
||||
require.Equal(t, source.AutoPauseOnExpired, duplicate.AutoPauseOnExpired)
|
||||
require.Equal(t, source.GroupIDs, duplicate.GroupIDs)
|
||||
require.Equal(t, source.Credentials, duplicate.Credentials)
|
||||
require.Equal(t, map[string]any{
|
||||
"config": map[string]any{"region": "us-east-1"},
|
||||
"items": []any{map[string]any{"enabled": true}},
|
||||
"quota_limit": float64(1000),
|
||||
"codex_cli_only": true,
|
||||
}, duplicate.Extra)
|
||||
require.NotContains(t, duplicate.Extra, UpstreamBillingRateSyncEnabledExtraKey)
|
||||
require.NotNil(t, duplicate.ExpiresAt)
|
||||
require.True(t, source.ExpiresAt.Equal(*duplicate.ExpiresAt))
|
||||
require.Equal(t, source.Notes, duplicate.Notes)
|
||||
require.Equal(t, source.ProxyFallbackOriginID, duplicate.ProxyID)
|
||||
require.Equal(t, source.RateMultiplier, duplicate.RateMultiplier)
|
||||
require.Equal(t, source.LoadFactor, duplicate.LoadFactor)
|
||||
require.Equal(t, source.GroupIDs, repo.groupsOf[duplicate.ID])
|
||||
require.Equal(t, []AccountGroup{
|
||||
{AccountID: duplicate.ID, GroupID: 7, Priority: 50},
|
||||
{AccountID: duplicate.ID, GroupID: 3, Priority: 7},
|
||||
}, repo.accountGroupsOf[duplicate.ID])
|
||||
|
||||
require.Equal(t, StatusActive, duplicate.Status)
|
||||
require.False(t, duplicate.Schedulable)
|
||||
require.Empty(t, duplicate.ErrorMessage)
|
||||
require.Nil(t, duplicate.LastUsedAt)
|
||||
require.Nil(t, duplicate.RateLimitedAt)
|
||||
require.Nil(t, duplicate.RateLimitResetAt)
|
||||
require.Nil(t, duplicate.OverloadUntil)
|
||||
require.Nil(t, duplicate.TempUnschedulableUntil)
|
||||
require.Empty(t, duplicate.TempUnschedulableReason)
|
||||
require.Nil(t, duplicate.SessionWindowStart)
|
||||
require.Nil(t, duplicate.SessionWindowEnd)
|
||||
require.Empty(t, duplicate.SessionWindowStatus)
|
||||
|
||||
duplicate.Credentials["nested"].(map[string]any)["token"] = "changed"
|
||||
duplicate.Extra["config"].(map[string]any)["region"] = "changed"
|
||||
duplicate.Extra["items"].([]any)[0].(map[string]any)["enabled"] = false
|
||||
storedSource, getErr := repo.GetByID(ctx, source.ID)
|
||||
require.NoError(t, getErr)
|
||||
require.Equal(t, "source-token", storedSource.Credentials["nested"].(map[string]any)["token"])
|
||||
require.Equal(t, "us-east-1", storedSource.Extra["config"].(map[string]any)["region"])
|
||||
require.Equal(t, true, storedSource.Extra["items"].([]any)[0].(map[string]any)["enabled"])
|
||||
require.Equal(t, "remote-42", storedSource.Extra["crs_account_id"])
|
||||
}
|
||||
|
||||
func TestDuplicateAccountRejectsCredentialShadow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDuplicateAccountRepoStub()
|
||||
svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo}
|
||||
parentID := int64(99)
|
||||
shadow := &Account{
|
||||
Name: "shadow",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
ParentAccountID: &parentID,
|
||||
QuotaDimension: QuotaDimensionSpark,
|
||||
}
|
||||
require.NoError(t, repo.Create(ctx, shadow))
|
||||
|
||||
_, err := svc.DuplicateAccount(ctx, shadow.ID, "admin:1", "")
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
|
||||
require.Equal(t, "ACCOUNT_DUPLICATE_SHADOW_UNSUPPORTED", infraerrors.Reason(err))
|
||||
require.Len(t, repo.accounts, 1)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountRejectsRotatingOrUnknownCredentialTypes(t *testing.T) {
|
||||
for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken, "legacy-cookie"} {
|
||||
t.Run(accountType, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDuplicateAccountRepoStub()
|
||||
svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo}
|
||||
source := &Account{
|
||||
Name: "rotating-credential-account",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: accountType,
|
||||
Credentials: map[string]any{"refresh_token": "shared-token"},
|
||||
}
|
||||
require.NoError(t, repo.Create(ctx, source))
|
||||
|
||||
_, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "")
|
||||
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
|
||||
require.Equal(t, "ACCOUNT_DUPLICATE_CREDENTIAL_TYPE_UNSUPPORTED", infraerrors.Reason(err))
|
||||
require.Len(t, repo.accounts, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateAccountPreservesUngroupedState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDuplicateAccountRepoStub()
|
||||
svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo}
|
||||
source := &Account{
|
||||
Name: "ungrouped",
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "secret"},
|
||||
GroupIDs: nil,
|
||||
}
|
||||
require.NoError(t, repo.Create(ctx, source))
|
||||
|
||||
duplicate, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, duplicate.GroupIDs)
|
||||
require.NotContains(t, repo.groupsOf, duplicate.ID)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountAtomicCreateFailureLeavesNoOrphan(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDuplicateAccountRepoStub()
|
||||
svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo}
|
||||
source := &Account{
|
||||
Name: "source",
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "secret"},
|
||||
GroupIDs: []int64{7},
|
||||
AccountGroups: []AccountGroup{{GroupID: 7, Priority: 25}},
|
||||
}
|
||||
require.NoError(t, repo.Create(ctx, source))
|
||||
repo.atomicCreateErr = errors.New("group binding failed")
|
||||
|
||||
_, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "")
|
||||
|
||||
require.ErrorContains(t, err, "group binding failed")
|
||||
require.Len(t, repo.accounts, 1)
|
||||
}
|
||||
|
||||
func TestDuplicateAccountNamePreservesSuffixWithinSchemaLimit(t *testing.T) {
|
||||
name := duplicateAccountName(strings.Repeat("界", 100))
|
||||
|
||||
require.Equal(t, 100, utf8.RuneCountInString(name))
|
||||
require.True(t, strings.HasSuffix(name, " (Copy)"))
|
||||
}
|
||||
|
||||
func TestDuplicateAccountReturnsExistingCopyForSameOperationKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newDuplicateAccountRepoStub()
|
||||
svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo}
|
||||
source := &Account{
|
||||
Name: "source",
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "secret"},
|
||||
}
|
||||
require.NoError(t, repo.Create(ctx, source))
|
||||
|
||||
first, err := svc.DuplicateAccount(ctx, source.ID, "admin:7", "stable-operation-key")
|
||||
require.NoError(t, err)
|
||||
second, err := svc.DuplicateAccount(ctx, source.ID, "admin:7", "stable-operation-key")
|
||||
require.NoError(t, err)
|
||||
recovered, err := svc.RecoverDuplicateAccount(ctx, source.ID, "admin:7", "stable-operation-key")
|
||||
require.NoError(t, err)
|
||||
otherAdminRecovery, err := svc.RecoverDuplicateAccount(ctx, source.ID, "admin:8", "stable-operation-key")
|
||||
require.NoError(t, err)
|
||||
otherAdminCopy, err := svc.DuplicateAccount(ctx, source.ID, "admin:8", "stable-operation-key")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, first.ID, second.ID)
|
||||
require.Equal(t, first.ID, recovered.ID)
|
||||
require.Nil(t, otherAdminRecovery, "durable recovery identity must remain scoped to the initiating admin")
|
||||
require.NotEqual(t, first.ID, otherAdminCopy.ID)
|
||||
require.Len(t, repo.accounts, 3)
|
||||
require.NotEmpty(t, first.Extra[duplicateAccountOperationIDExtraKey])
|
||||
}
|
||||
Reference in New Issue
Block a user