Files
sub2api/backend/internal/service/admin_service_duplicate_account_test.go
T
yan9651688 f7da6e2bc6 fix(accounts): prevent duplicate retries from crossing admins
Ambiguous idempotency-store failures can occur after the account transaction commits. Scope durable recovery markers to the authenticated admin, retain the operation key across reloads, and recover only an already committed copy without rerunning active work.

Constraint: Generic idempotent handlers may legitimately remain active while a recovery lookup is attempted

Rejected: Reclaim or rerun an in-progress duplicate request | can execute account creation concurrently

Rejected: Recover by source account and key alone | allows another admin to observe the committed copy

Confidence: high

Scope-risk: narrow

Reversibility: clean

Directive: Keep ambiguous-response recovery read-only and bind durable operation markers to the authenticated actor

Tested: Full Go unit suite, go vet, server build, integration-test compilation; frontend lint, typecheck, 1,030 Vitest tests, and production build

Not-tested: Docker-backed PostgreSQL integration runtime because Docker is unavailable

Related: Wei-Shaw/sub2api#1379

Related: Wei-Shaw/sub2api#2928
2026-07-15 10:51:13 +08:00

323 lines
12 KiB
Go

//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: &notes,
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",
}
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.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])
}