Files
sub2api/backend/internal/service/admin_service_spark_shadow_test.go
T
李建琦 6d655c9903
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
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

1034 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build unit
package service
import (
"context"
"errors"
"net/http"
"testing"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/stretchr/testify/require"
)
// sparkShadowRepoStub 是 AccountRepository 的内存测试桩,
// 专为 CreateShadow 单元测试设计。
// 嵌入 mockAccountRepoForGemini(由 gemini_multiplatform_test.go 提供所有 stub 方法),
// 并覆盖测试所需的核心方法。
type sparkShadowRepoStub struct {
mockAccountRepoForGemini
nextID int64
accounts map[int64]*Account
groupsOf map[int64][]int64 // accountID → []groupIDs
}
func newSparkShadowRepoStub() *sparkShadowRepoStub {
return &sparkShadowRepoStub{
nextID: 0,
accounts: make(map[int64]*Account),
groupsOf: make(map[int64][]int64),
mockAccountRepoForGemini: mockAccountRepoForGemini{
accountsByID: make(map[int64]*Account),
},
}
}
func (s *sparkShadowRepoStub) Create(_ context.Context, account *Account) error {
s.nextID++
account.ID = s.nextID
cp := *account
s.accounts[account.ID] = &cp
s.mockAccountRepoForGemini.accountsByID[account.ID] = &cp
return nil
}
func (s *sparkShadowRepoStub) GetByID(_ context.Context, id int64) (*Account, error) {
acc, ok := s.accounts[id]
if !ok {
return nil, ErrAccountNotFound
}
return acc, nil
}
func (s *sparkShadowRepoStub) ListShadowsByParent(_ context.Context, parentID int64) ([]*Account, error) {
var result []*Account
for _, acc := range s.accounts {
if acc.ParentAccountID != nil && *acc.ParentAccountID == parentID && acc.QuotaDimension == QuotaDimensionSpark {
cp := *acc
result = append(result, &cp)
}
}
return result, nil
}
func (s *sparkShadowRepoStub) BindGroups(_ context.Context, accountID int64, groupIDs []int64) error {
s.groupsOf[accountID] = append(s.groupsOf[accountID], groupIDs...)
return nil
}
func (s *sparkShadowRepoStub) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) {
var result []Account
for accID, groups := range s.groupsOf {
for _, gid := range groups {
if gid == groupID {
if acc, ok := s.accounts[accID]; ok {
result = append(result, *acc)
}
break
}
}
}
return result, nil
}
// ListWithFilters は mockAccountRepoForGemini にないが AccountRepository が要求する。
// 親の mockAccountRepoForGemini の nil 実装が継承されるため、ここでは省略可。
// ── 追加 stubAccountRepository に必要な残りのメソッド)──────────────────
func (s *sparkShadowRepoStub) ExistsByID(_ context.Context, id int64) (bool, error) {
_, ok := s.accounts[id]
return ok, nil
}
func (s *sparkShadowRepoStub) Update(_ context.Context, account *Account) error {
if _, ok := s.accounts[account.ID]; !ok {
return ErrAccountNotFound
}
cp := *account
s.accounts[account.ID] = &cp
s.mockAccountRepoForGemini.accountsByID[account.ID] = &cp
return nil
}
func (s *sparkShadowRepoStub) Delete(_ context.Context, id int64) error {
delete(s.accounts, id)
delete(s.mockAccountRepoForGemini.accountsByID, id)
return nil
}
func (s *sparkShadowRepoStub) BatchUpdateLastUsed(_ context.Context, _ map[int64]time.Time) error {
return nil
}
func (s *sparkShadowRepoStub) ListByGroup(_ context.Context, _ int64) ([]Account, error) {
return nil, nil
}
func (s *sparkShadowRepoStub) ListWithFilters(_ context.Context, _ pagination.PaginationParams, _, _, _, _ string, _ int64, _ string) ([]Account, *pagination.PaginationResult, error) {
return nil, nil, nil
}
// TestCreateShadow はメインのシナリオを検証する。
//
// Test 1 — 基本生成: ParentAccountID / QuotaDimension / 默认 spark model_mapping / 无 auth token / ProxyID 継承
// Test 2 — 一母一影: 二度目の生成はエラー
func TestCreateShadow(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
proxyID := int64(7)
parent := &Account{
Name: "p",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
ProxyID: &proxyID,
Credentials: map[string]any{
"refresh_token": "RT",
"chatgpt_account_id": "org-x",
},
}
require.NoError(t, repo.Create(ctx, parent))
// Test 1: 基本生成
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "p-spark", Priority: 50})
require.NoError(t, err)
require.NotNil(t, shadow)
require.Equal(t, parent.ID, *shadow.ParentAccountID)
require.Equal(t, QuotaDimensionSpark, shadow.QuotaDimension)
require.Equal(t, defaultSparkShadowModelMapping(), shadow.Credentials["model_mapping"],
"影子默认带 spark 恒等变体映射")
require.Nil(t, shadow.Credentials["refresh_token"], "影子不得持有 auth token")
require.Nil(t, shadow.Credentials["access_token"], "影子不得持有 auth token")
require.Equal(t, parent.ProxyID, shadow.ProxyID)
// Test 2: 一母一影 — 再作成は拒否
_, err = svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "dup"})
require.Error(t, err)
}
func TestCreateShadowInheritsParentEffectiveOpenAILongContextBillingValue(t *testing.T) {
tests := []struct {
name string
parentExtra map[string]any
want bool
}{
{name: "missing parent value defaults disabled", want: false},
{name: "explicit parent opt-out is inherited", parentExtra: map[string]any{openAILongContextBillingEnabledKey: false}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "parent",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Credentials: map[string]any{"access_token": "token"},
Extra: tt.parentExtra,
}
require.NoError(t, repo.Create(context.Background(), parent))
shadow, err := svc.CreateShadow(context.Background(), parent.ID, ShadowOptions{Name: "shadow"})
require.NoError(t, err)
require.Equal(t, tt.want, shadow.Extra[openAILongContextBillingEnabledKey])
require.Equal(t, tt.want, shadow.IsOpenAILongContextBillingEnabled())
})
}
}
// TestCreateShadow_BindGroups は BindGroups の後置呼び出しを検証する。
// 影子账号が指定グループに属し、ListSchedulableByGroupID で取得可能であること。
func TestCreateShadow_BindGroups(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "parent",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Credentials: map[string]any{
"chatgpt_account_id": "org-y",
},
}
require.NoError(t, repo.Create(ctx, parent))
const testGroupID = int64(42)
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{
Name: "p-spark",
GroupIDs: []int64{testGroupID},
})
require.NoError(t, err)
require.NotNil(t, shadow)
require.Equal(t, []int64{testGroupID}, shadow.GroupIDs, "CreateShadow should backfill GroupIDs into the returned shadow")
accounts, err := repo.ListSchedulableByGroupID(ctx, testGroupID)
require.NoError(t, err)
require.Len(t, accounts, 1)
require.Equal(t, shadow.ID, accounts[0].ID)
}
// TestDeleteAccount_CascadeToShadow verifies that deleting a parent account also
// deletes its spark shadow account.
// TestCreateShadow_InheritsParentConcurrency 验证外审 F3:未指定并发时
// 影子继承母账号并发,避免 Concurrency=0 被限流器当作"无限并发"。
func TestCreateShadow_InheritsParentConcurrency(t *testing.T) {
ctx := context.Background()
t.Run("unspecified_inherits_parent", func(t *testing.T) {
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "conc-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Concurrency: 3,
Credentials: map[string]any{"chatgpt_account_id": "org-c"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "conc-shadow"})
require.NoError(t, err)
require.Equal(t, 3, shadow.Concurrency, "未指定并发应继承母账号(非 0=无限)")
require.Equal(t, 3, repo.accounts[shadow.ID].Concurrency)
})
t.Run("explicit_positive_kept", func(t *testing.T) {
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "conc-parent2", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Concurrency: 3,
Credentials: map[string]any{"chatgpt_account_id": "org-c2"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "conc-shadow2", Concurrency: 2})
require.NoError(t, err)
require.Equal(t, 2, shadow.Concurrency, "显式正并发应保留")
})
}
// TestCreateShadow_InheritsParentPriorityWhenOmitted 验证外审第5轮 P1:未指定优先级时
// 影子继承母账号 priority,而非直写 0 抢到最高调度优先级(repo SetPriority 绕过 ent 默认 50,
// 调度比较数值越小越优先;前端一键创建只传 name 即触发该路径)。
func TestCreateShadow_InheritsParentPriorityWhenOmitted(t *testing.T) {
ctx := context.Background()
t.Run("unspecified_inherits_parent", func(t *testing.T) {
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "prio-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Priority: 30,
Credentials: map[string]any{"chatgpt_account_id": "org-p"},
}
require.NoError(t, repo.Create(ctx, parent))
// 模拟前端一键创建:只传 name,priority 省略=0。
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "prio-shadow"})
require.NoError(t, err)
require.Equal(t, 30, shadow.Priority, "未指定优先级应继承母账号(而非 0=最高优先级)")
require.Equal(t, 30, repo.accounts[shadow.ID].Priority)
})
t.Run("explicit_positive_kept", func(t *testing.T) {
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "prio-parent2", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Priority: 30,
Credentials: map[string]any{"chatgpt_account_id": "org-p2"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "prio-shadow2", Priority: 7})
require.NoError(t, err)
require.Equal(t, 7, shadow.Priority, "显式正优先级应保留")
})
}
// TestPersistAccountCredentials_SkipsShadow 验证外审第6轮 P1:凭据写入唯一汇聚点
// persistAccountCredentials 对 spark 影子早返 no-op,任何上游路径都无法把凭据落到影子行。
func TestPersistAccountCredentials_SkipsShadow(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
parentID := int64(1)
shadow := &Account{
Name: "shadow", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Credentials: map[string]any{},
ParentAccountID: &parentID, QuotaDimension: QuotaDimensionSpark,
}
require.NoError(t, repo.Create(ctx, shadow)) // 回填 shadow.ID,使「漏判则 Update 成功」可被检出
err := persistAccountCredentials(ctx, repo, shadow, map[string]any{"access_token": "LEAK", "refresh_token": "LEAK"})
require.NoError(t, err)
require.Empty(t, shadow.Credentials, "影子凭据不可被写入(传入对象)")
require.Empty(t, repo.accounts[shadow.ID].Credentials, "影子凭据不可被写入(仓储)")
}
// TestResolveCredentialAccount_RejectsParentShadow 验证外审第6轮 P2 防御:畸形数据/手工 DB
// 写出的「影子→影子」链,凭据解析必须 fail-closed 而非停在无凭据的一级影子。
func TestResolveCredentialAccount_RejectsParentShadow(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
grandparent := &Account{
Name: "gp", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive,
Credentials: map[string]any{"refresh_token": "RT"},
}
require.NoError(t, repo.Create(ctx, grandparent))
// parent 本身是影子(非法二级结构)
parentShadow := &Account{
Name: "parent-shadow", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive,
Credentials: map[string]any{}, ParentAccountID: &grandparent.ID, QuotaDimension: QuotaDimensionSpark,
}
require.NoError(t, repo.Create(ctx, parentShadow))
child := &Account{
Name: "child-shadow", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive,
Credentials: map[string]any{}, ParentAccountID: &parentShadow.ID, QuotaDimension: QuotaDimensionSpark,
}
require.NoError(t, repo.Create(ctx, child))
_, err := resolveCredentialAccount(ctx, repo, child)
require.Error(t, err, "父账号本身是影子时凭据解析应拒绝(fail-closed)")
}
// TestPersistOpenAI429PlanType_SkipsShadow 验证外审第7轮 P1:429 plan_type 同步走 BulkUpdate 直写
// (不经 persistAccountCredentials),必须对影子早返,否则会把 plan_type 写进影子 credentials。
func TestPersistOpenAI429PlanType_SkipsShadow(t *testing.T) {
ctx := context.Background()
body := []byte(`{"error":{"type":"usage_limit_reached","plan_type":"pro"}}`)
parentID := int64(1)
t.Run("shadow_skipped", func(t *testing.T) {
repo := newSparkShadowRepoStub()
shadow := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{}, ParentAccountID: &parentID}
persistOpenAI429PlanType(ctx, repo, shadow, body)
require.Empty(t, shadow.Credentials, "影子不可被写入 plan_type 凭据")
})
t.Run("normal_account_writes", func(t *testing.T) {
repo := newSparkShadowRepoStub()
normal := &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{}}
persistOpenAI429PlanType(ctx, repo, normal, body)
require.Equal(t, "pro", normal.Credentials["plan_type"], "普通账号应写入 plan_type(反向对照,证明 body 有效、写路径通)")
})
}
// updateExtraSpyRepo 记录 UpdateExtra 是否被调用,用于验证影子 codex_* 快照守卫。
type updateExtraSpyRepo struct {
*sparkShadowRepoStub
updateExtraCalled bool
}
func (r *updateExtraSpyRepo) UpdateExtra(_ context.Context, _ int64, _ map[string]any) error {
r.updateExtraCalled = true
return nil
}
// TestPersistOpenAICodexSnapshot_SkipsShadow 验证外审第7轮 P1:影子 codex_* 仅由 QueryUsage
// (/wham/usage bengalfox)更新,不能被 429 路径的 x-codex-* 全局头快照污染。
func TestPersistOpenAICodexSnapshot_SkipsShadow(t *testing.T) {
headers := http.Header{}
headers.Set("x-codex-primary-used-percent", "50")
parentID := int64(1)
t.Run("shadow_skipped", func(t *testing.T) {
spy := &updateExtraSpyRepo{sparkShadowRepoStub: newSparkShadowRepoStub()}
s := &RateLimitService{accountRepo: spy}
shadow := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID}
s.persistOpenAICodexSnapshot(context.Background(), shadow, headers)
require.False(t, spy.updateExtraCalled, "影子不应写 codex_* 头快照")
})
t.Run("normal_account_writes", func(t *testing.T) {
spy := &updateExtraSpyRepo{sparkShadowRepoStub: newSparkShadowRepoStub()}
s := &RateLimitService{accountRepo: spy}
normal := &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
s.persistOpenAICodexSnapshot(context.Background(), normal, headers)
require.True(t, spy.updateExtraCalled, "普通账号应写 codex_* 头快照(反向对照)")
})
}
// TestResetAccountQuota_RejectsShadow 验证外审第7轮 P2:通用 reset-quota 对影子明确 400 拒绝
// (影子不持自有配额,语义不一致),且母账号仍可正常重置。
func TestResetAccountQuota_RejectsShadow(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "rq-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive,
Credentials: map[string]any{"chatgpt_account_id": "o"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "rq-shadow"})
require.NoError(t, err)
err = svc.ResetAccountQuota(ctx, shadow.ID)
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "影子 reset-quota 应 400")
require.NoError(t, svc.ResetAccountQuota(ctx, parent.ID), "母账号 reset-quota 应放行")
}
// sparkShadowGroupRepoStub 嵌入 groupRepoStub(其余方法 panic),仅覆写
// ListActiveByPlatform 以供 F4 默认绑组测试。
type sparkShadowGroupRepoStub struct {
groupRepoStub
groups []Group
}
func (s *sparkShadowGroupRepoStub) ListActiveByPlatform(_ context.Context, _ string) ([]Group, error) {
return s.groups, nil
}
// TestCreateShadow_DefaultGroupBinding 验证外审 F4:未指定 group_ids 时
// 影子回落绑定 openai-default 组(否则无组、组内路由选不到)。
func TestCreateShadow_DefaultGroupBinding(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
groupRepo := &sparkShadowGroupRepoStub{
groups: []Group{
{ID: 99, Name: PlatformOpenAI + "-default"},
{ID: 7, Name: "some-other-group"},
},
}
svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo}
parent := &Account{
Name: "grp-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "org-g"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "grp-shadow"})
require.NoError(t, err)
require.Equal(t, []int64{99}, repo.groupsOf[shadow.ID], "未指定分组应回落绑定 openai-default(id=99)")
}
// TestCreateShadow_InheritsParentGroups 验证外审 G1:未指定 group_ids 时
// 影子继承母账号当前分组(而非仅 openai-default),以便母在自定义组时影子也可路由。
func TestCreateShadow_InheritsParentGroups(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
// groupRepo 故意提供 openai-default,以证明「继承母分组」优先于「回落 openai-default」。
groupRepo := &sparkShadowGroupRepoStub{groups: []Group{{ID: 99, Name: PlatformOpenAI + "-default"}}}
svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo}
parent := &Account{
Name: "grp-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, GroupIDs: []int64{11, 22},
Credentials: map[string]any{"chatgpt_account_id": "org-grp"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "grp-shadow"})
require.NoError(t, err)
require.Equal(t, []int64{11, 22}, repo.groupsOf[shadow.ID], "未指定分组应继承母账号分组,而非 openai-default")
}
// TestCreateShadow_RejectsShadowAsParent 验证外审 G6:不允许把影子当母创建二级影子。
func TestCreateShadow_RejectsShadowAsParent(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "real-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "org-x"},
}
require.NoError(t, repo.Create(ctx, parent))
firstShadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "first-shadow"})
require.NoError(t, err)
// 把一级影子当母 → 必须被拒(400)。
_, err = svc.CreateShadow(ctx, firstShadow.ID, ShadowOptions{Name: "second-shadow"})
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "影子当母应返回 400")
}
// TestCreateShadow_StructuredErrors 验证外审 G3:可预期业务错误返回结构化 4xx 而非 500。
func TestCreateShadow_StructuredErrors(t *testing.T) {
ctx := context.Background()
t.Run("non_oauth_parent_400", func(t *testing.T) {
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{Name: "apikey-parent", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive}
require.NoError(t, repo.Create(ctx, parent))
_, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"})
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "非 OAuth 母账号应 400")
})
t.Run("duplicate_409", func(t *testing.T) {
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"}}
require.NoError(t, repo.Create(ctx, parent))
_, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s1"})
require.NoError(t, err)
_, err = svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s2"})
require.Error(t, err)
require.Equal(t, http.StatusConflict, infraerrors.Code(err), "重复创建应 409")
})
}
// TestUpdateAccount_RejectsTypeChangeOnShadow 验证外审 G7:影子 type 不可被普通更新改坏。
func TestUpdateAccount_RejectsTypeChangeOnShadow(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "type-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "org-t"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "type-shadow"})
require.NoError(t, err)
// 试图把影子 type 改成 apikey → 必须被拒(400)。
_, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{Type: AccountTypeAPIKey})
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "改影子 type 应 400")
require.Equal(t, AccountTypeOAuth, repo.accounts[shadow.ID].Type, "影子 type 必须保持 oauth")
// 传入相同 type(oauth)为 no-op,应允许。
_, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{Type: AccountTypeOAuth})
require.NoError(t, err, "传入相同 type 应允许")
}
// TestBulkUpdateAccounts_RejectsCredentialWriteToShadow 验证外审 G5:批量更新携带凭据时
// 目标含影子必须被拒(与单账号 UpdateAccount 守卫对齐,堵住 bulk 绕过)。
func TestBulkUpdateAccounts_RejectsCredentialWriteToShadow(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "bulk-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "org-b", "access_token": "t"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "bulk-shadow"})
require.NoError(t, err)
_, err = svc.BulkUpdateAccounts(ctx, &BulkUpdateAccountsInput{
AccountIDs: []int64{shadow.ID},
Credentials: map[string]any{"access_token": "leaked"},
})
require.Error(t, err, "批量给影子写凭据必须被拒")
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "应 400")
// Credentials 允许持有 model_mapping(CreateShadow 写入的默认值),该不变量只约束
// 鉴权凭据不可泄露到影子——不能整体断言 Credentials 为空。
require.Empty(t, repo.accounts[shadow.ID].GetOpenAIAccessToken(), "影子 access_token 必须保持为空 —— 批量写入未生效")
}
func TestDeleteAccount_CascadeToShadow(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "cascade-parent",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Credentials: map[string]any{"chatgpt_account_id": "org-cascade"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "cascade-shadow"})
require.NoError(t, err)
shadowID := shadow.ID
// Both accounts exist before delete.
_, ok := repo.accounts[parent.ID]
require.True(t, ok)
_, ok = repo.accounts[shadowID]
require.True(t, ok)
require.NoError(t, svc.DeleteAccount(ctx, parent.ID))
// Parent is gone.
_, ok = repo.accounts[parent.ID]
require.False(t, ok, "parent account should be deleted")
// Shadow is also gone (cascade).
_, ok = repo.accounts[shadowID]
require.False(t, ok, "shadow account should be cascade-deleted")
}
// TestUpdateAccount_PropagatesProxyToShadow verifies that updating a parent
// account's ProxyID propagates the new value to its spark shadow.
func TestUpdateAccount_PropagatesProxyToShadow(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
oldProxy := int64(7)
parent := &Account{
Name: "proxy-parent",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
ProxyID: &oldProxy,
Credentials: map[string]any{"chatgpt_account_id": "org-proxy"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "proxy-shadow"})
require.NoError(t, err)
shadowID := shadow.ID
// Update parent's ProxyID.
newProxy := int64(42)
_, err = svc.UpdateAccount(ctx, parent.ID, &UpdateAccountInput{ProxyID: &newProxy})
require.NoError(t, err)
// Shadow must carry the new ProxyID.
storedShadow, ok := repo.accounts[shadowID]
require.True(t, ok)
require.NotNil(t, storedShadow.ProxyID)
require.Equal(t, newProxy, *storedShadow.ProxyID)
}
// TestUpdateAccount_RejectsCredentialWriteToShadow 验证安全不变量「影子绝不持有鉴权凭据」
// 在通用更新路径(UpdateAccount,被 edit/re-auth/refresh/batch 共用)上也被守住:
// 对影子写入 access_token/refresh_token 必须被拒绝,且影子的 access_token/refresh_token
// 保持为空(Credentials 本身允许持有 CreateShadow 写入的 model_mapping,故不能断言整体为空)。
func TestUpdateAccount_RejectsCredentialWriteToShadow(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "cred-parent",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Credentials: map[string]any{"access_token": "parent-secret", "refresh_token": "parent-rt"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "cred-shadow"})
require.NoError(t, err)
require.Empty(t, shadow.GetOpenAIAccessToken(), "前提:影子创建后不持有 access_token")
require.Empty(t, shadow.GetOpenAIRefreshToken(), "前提:影子创建后不持有 refresh_token")
// 试图给影子写入凭据 → 必须被拒绝。
_, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{
Credentials: map[string]any{"access_token": "leaked", "refresh_token": "leaked-rt"},
})
require.Error(t, err, "对影子写入凭据必须被拒绝")
// 结构化 4xx(非裸 error→500)。
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "应映射为 400 而非 500")
// 影子的 access_token/refresh_token 仍为空 —— 凭据未被写入。
storedShadow, ok := repo.accounts[shadow.ID]
require.True(t, ok)
require.Empty(t, storedShadow.GetOpenAIAccessToken(), "影子 access_token 必须保持为空 —— 凭据未被写入")
require.Empty(t, storedShadow.GetOpenAIRefreshToken(), "影子 refresh_token 必须保持为空 —— 凭据未被写入")
// 对照:不带凭据的字段更新(如 Priority)仍应成功。
newPriority := 5
_, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{Priority: &newPriority})
require.NoError(t, err, "影子的非凭据字段更新应正常")
}
// TestBulkUpdateAccounts_PropagatesProxyToShadow verifies that bulk-updating
// accounts' ProxyID propagates the new value to each account's spark shadow.
func TestBulkUpdateAccounts_PropagatesProxyToShadow(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
oldProxy := int64(7)
parent := &Account{
Name: "bulk-parent",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
ProxyID: &oldProxy,
Credentials: map[string]any{"chatgpt_account_id": "org-bulk"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "bulk-shadow"})
require.NoError(t, err)
shadowID := shadow.ID
// Bulk update parent's ProxyID.
newProxy := int64(99)
_, err = svc.BulkUpdateAccounts(ctx, &BulkUpdateAccountsInput{
AccountIDs: []int64{parent.ID},
ProxyID: &newProxy,
})
require.NoError(t, err)
// Shadow must carry the new ProxyID.
storedShadow, ok := repo.accounts[shadowID]
require.True(t, ok)
require.NotNil(t, storedShadow.ProxyID)
require.Equal(t, newProxy, *storedShadow.ProxyID)
}
// ── 外审 P1/P2 加固:专用测试桩 ───────────────────────────────────────────
// raceCreateRepoStub 模拟并发竞态:对影子的 Create 撞一母一影唯一索引(返回错误),
// 且复查时另一并发请求的影子已存在 → CreateShadow 应映射为结构化 409(外审 A/P1)。
type raceCreateRepoStub struct {
*sparkShadowRepoStub
}
func (s *raceCreateRepoStub) Create(ctx context.Context, account *Account) error {
if account.ParentAccountID != nil {
// 模拟另一并发请求已抢先建成影子:注入底层 map,本次 Create 撞唯一索引失败。
s.sparkShadowRepoStub.nextID++
phantom := *account
phantom.ID = s.sparkShadowRepoStub.nextID
s.sparkShadowRepoStub.accounts[phantom.ID] = &phantom
return errors.New(`duplicate key value violates unique constraint "uq_accounts_spark_shadow_per_parent"`)
}
return s.sparkShadowRepoStub.Create(ctx, account)
}
// bindFailRepoStub 让 BindGroups 失败,用于验证绑组失败时补偿删除刚建的影子(外审 C/P1)。
type bindFailRepoStub struct {
*sparkShadowRepoStub
}
func (s *bindFailRepoStub) BindGroups(_ context.Context, _ int64, _ []int64) error {
return errors.New("simulated bind failure")
}
// sparkShadowValidatingGroupRepoStub 实现 groupExistenceBatchReader(ExistsByIDs),
// 使 validateGroupIDsExist 走批量存在性校验路径。
type sparkShadowValidatingGroupRepoStub struct {
groupRepoStub
existing map[int64]bool
}
func (s *sparkShadowValidatingGroupRepoStub) ExistsByIDs(_ context.Context, ids []int64) (map[int64]bool, error) {
out := make(map[int64]bool, len(ids))
for _, id := range ids {
out[id] = s.existing[id]
}
return out, nil
}
// TestCreateShadow_DefaultsNameFromParent 验证外审 E/P2:空 name 不应 500,
// 而是默认 "<母账号名> (Spark)"。
func TestCreateShadow_DefaultsNameFromParent(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "mum", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: " "})
require.NoError(t, err, "空/空白 name 不应 500,应默认命名")
require.Equal(t, "mum (Spark)", shadow.Name)
}
// TestCreateShadow_ConcurrentCreateReturns409 验证外审 A/P1:并发竞态下预查放行后
// Create 撞唯一索引,应映射结构化 409 而非裸 500。
func TestCreateShadow_ConcurrentCreateReturns409(t *testing.T) {
ctx := context.Background()
base := newSparkShadowRepoStub()
repo := &raceCreateRepoStub{sparkShadowRepoStub: base}
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"},
}
require.NoError(t, base.Create(ctx, parent))
_, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"})
require.Error(t, err)
require.Equal(t, http.StatusConflict, infraerrors.Code(err), "并发竞态撞唯一索引应映射 409 而非 500")
}
// TestCreateShadow_InvalidGroupRejectedNoOrphan 验证外审 C/P1:显式无效分组应在
// 创建前被拒,不留孤儿影子。
func TestCreateShadow_InvalidGroupRejectedNoOrphan(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
groupRepo := &sparkShadowValidatingGroupRepoStub{existing: map[int64]bool{7: true}}
svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo}
parent := &Account{
Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"},
}
require.NoError(t, repo.Create(ctx, parent))
_, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s", GroupIDs: []int64{999}})
require.Error(t, err, "无效分组应在创建前被拒")
shadows, qerr := repo.ListShadowsByParent(ctx, parent.ID)
require.NoError(t, qerr)
require.Empty(t, shadows, "无效分组应在创建前被拒,不应建出影子")
}
// TestCreateShadow_BindFailureRollsBackShadow 验证外审 C/P1:绑组失败时补偿删除
// 刚建的影子,不留孤儿(否则一母一影唯一索引会挡住重试)。
func TestCreateShadow_BindFailureRollsBackShadow(t *testing.T) {
ctx := context.Background()
base := newSparkShadowRepoStub()
repo := &bindFailRepoStub{sparkShadowRepoStub: base}
groupRepo := &sparkShadowValidatingGroupRepoStub{existing: map[int64]bool{7: true}}
svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo}
parent := &Account{
Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"},
}
require.NoError(t, base.Create(ctx, parent))
_, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s", GroupIDs: []int64{7}})
require.Error(t, err, "绑组失败应返回错误")
shadows, qerr := base.ListShadowsByParent(ctx, parent.ID)
require.NoError(t, qerr)
require.Empty(t, shadows, "绑组失败后应补偿删除影子,不留孤儿")
}
// TestUpdateAccount_RejectsParentTypeChangeWithShadow 验证外审 D/P1:母账号有 spark 影子时,
// 不能把 type 改出 OpenAI OAuth(否则影子被调度后透传凭据解析必失败)。
func TestUpdateAccount_RejectsParentTypeChangeWithShadow(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parent := &Account{
Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"},
}
require.NoError(t, repo.Create(ctx, parent))
_, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"})
require.NoError(t, err)
_, err = svc.UpdateAccount(ctx, parent.ID, &UpdateAccountInput{Type: AccountTypeAPIKey})
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "母账号有影子时改 type 出 oauth 应 400")
require.Equal(t, AccountTypeOAuth, repo.accounts[parent.ID].Type, "母账号 type 必须保持 oauth")
// 对照:把 type 设为相同 oauth(no-op)应允许。
_, err = svc.UpdateAccount(ctx, parent.ID, &UpdateAccountInput{Type: AccountTypeOAuth})
require.NoError(t, err, "传入相同 type(no-op)应允许")
}
// TestUpdateAccount_IgnoresProxyChangeOnShadow 验证外审 B/P1:影子 proxy 恒继承母账号,
// 普通更新不得独立改动。
func TestUpdateAccount_IgnoresProxyChangeOnShadow(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parentProxy := int64(7)
parent := &Account{
Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, ProxyID: &parentProxy,
Credentials: map[string]any{"chatgpt_account_id": "o"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"})
require.NoError(t, err)
require.NotNil(t, repo.accounts[shadow.ID].ProxyID)
require.Equal(t, parentProxy, *repo.accounts[shadow.ID].ProxyID, "前提:影子继承母 proxy=7")
newProxy := int64(42)
_, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{ProxyID: &newProxy})
require.NoError(t, err, "影子的非 proxy 字段更新仍应成功")
require.NotNil(t, repo.accounts[shadow.ID].ProxyID)
require.Equal(t, parentProxy, *repo.accounts[shadow.ID].ProxyID, "影子 proxy 不应被独立改动,恒继承母账号")
}
func TestUpdateAccount_ShadowAllowsModelMappingAndGroupUpdate(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
groupRepo := &sparkShadowValidatingGroupRepoStub{existing: map[int64]bool{7: true}}
svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo}
parentID := int64(1)
parent := &Account{
ID: parentID,
Name: "p",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
Credentials: map[string]any{
"access_token": "parent-token",
"chatgpt_account_id": "org-parent",
},
}
require.NoError(t, repo.Create(ctx, parent))
shadow := &Account{
Name: "s",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
ParentAccountID: &parentID,
QuotaDimension: QuotaDimensionSpark,
Credentials: map[string]any{},
}
require.NoError(t, repo.Create(ctx, shadow))
groupIDs := []int64{7}
updated, err := svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{
Credentials: map[string]any{
"model_mapping": map[string]any{
"gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
},
},
GroupIDs: &groupIDs,
})
require.NoError(t, err)
require.Equal(t, []int64{7}, repo.groupsOf[shadow.ID])
require.Equal(t, map[string]any{"gpt-5.3-codex-spark": "gpt-5.3-codex-spark"}, updated.Credentials["model_mapping"])
require.Empty(t, updated.GetOpenAIAccessToken(), "影子账号不可持有母账号 access_token")
}
func TestUpdateAccount_ShadowEmptyCredentialsClearsModelMapping(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parentID := int64(1)
shadow := &Account{
Name: "s",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
ParentAccountID: &parentID,
QuotaDimension: QuotaDimensionSpark,
Credentials: map[string]any{
"model_mapping": map[string]any{
"gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
},
},
}
require.NoError(t, repo.Create(ctx, shadow))
updated, err := svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{
Credentials: map[string]any{},
})
require.NoError(t, err)
require.Empty(t, updated.Credentials)
require.Empty(t, repo.accounts[shadow.ID].Credentials)
}
func TestUpdateAccount_ShadowRejectsAuthCredentials(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parentID := int64(1)
shadow := &Account{
Name: "s",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Status: StatusActive,
ParentAccountID: &parentID,
QuotaDimension: QuotaDimensionSpark,
Credentials: map[string]any{},
}
require.NoError(t, repo.Create(ctx, shadow))
_, err := svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{
Credentials: map[string]any{"access_token": "leak"},
})
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
require.Empty(t, repo.accounts[shadow.ID].Credentials)
}
// TestBulkUpdateAccounts_RejectsProxyChangeOnShadow 验证外审第4轮 P1:批量更新携带 proxy 且
// 目标含影子必须被拒(与单账号 UpdateAccount 守卫对齐,堵住 bulk 绕过"proxy 恒继承母账号")。
func TestBulkUpdateAccounts_RejectsProxyChangeOnShadow(t *testing.T) {
ctx := context.Background()
repo := newSparkShadowRepoStub()
svc := &adminServiceImpl{accountRepo: repo}
parentProxy := int64(7)
parent := &Account{
Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusActive, ProxyID: &parentProxy,
Credentials: map[string]any{"chatgpt_account_id": "o"},
}
require.NoError(t, repo.Create(ctx, parent))
shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"})
require.NoError(t, err)
newProxy := int64(42)
_, err = svc.BulkUpdateAccounts(ctx, &BulkUpdateAccountsInput{
AccountIDs: []int64{shadow.ID},
ProxyID: &newProxy,
})
require.Error(t, err, "批量给影子改 proxy 必须被拒")
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "应 400")
require.NotNil(t, repo.accounts[shadow.ID].ProxyID)
require.Equal(t, parentProxy, *repo.accounts[shadow.ID].ProxyID, "影子 proxy 必须保持继承母账号")
}
// TestForceOpenAIPrivacy_SkipsShadow 验证外审第4轮:影子隐私设置跳过(由母账号管理),
// 早返不触碰任何依赖(svc 无 deps,若未守卫会 nil panic)。
func TestForceOpenAIPrivacy_SkipsShadow(t *testing.T) {
svc := &adminServiceImpl{}
pid := int64(1)
shadow := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &pid}
require.Equal(t, "", svc.ForceOpenAIPrivacy(context.Background(), shadow), "影子隐私设置应跳过")
}