admin_service_spark_shadow_test.go 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "net/http"
  7. "testing"
  8. "time"
  9. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  11. "github.com/stretchr/testify/require"
  12. )
  13. // sparkShadowRepoStub 是 AccountRepository 的内存测试桩,
  14. // 专为 CreateShadow 单元测试设计。
  15. // 嵌入 mockAccountRepoForGemini(由 gemini_multiplatform_test.go 提供所有 stub 方法),
  16. // 并覆盖测试所需的核心方法。
  17. type sparkShadowRepoStub struct {
  18. mockAccountRepoForGemini
  19. nextID int64
  20. accounts map[int64]*Account
  21. groupsOf map[int64][]int64 // accountID → []groupIDs
  22. }
  23. func newSparkShadowRepoStub() *sparkShadowRepoStub {
  24. return &sparkShadowRepoStub{
  25. nextID: 0,
  26. accounts: make(map[int64]*Account),
  27. groupsOf: make(map[int64][]int64),
  28. mockAccountRepoForGemini: mockAccountRepoForGemini{
  29. accountsByID: make(map[int64]*Account),
  30. },
  31. }
  32. }
  33. func (s *sparkShadowRepoStub) Create(_ context.Context, account *Account) error {
  34. s.nextID++
  35. account.ID = s.nextID
  36. cp := *account
  37. s.accounts[account.ID] = &cp
  38. s.mockAccountRepoForGemini.accountsByID[account.ID] = &cp
  39. return nil
  40. }
  41. func (s *sparkShadowRepoStub) GetByID(_ context.Context, id int64) (*Account, error) {
  42. acc, ok := s.accounts[id]
  43. if !ok {
  44. return nil, ErrAccountNotFound
  45. }
  46. return acc, nil
  47. }
  48. func (s *sparkShadowRepoStub) ListShadowsByParent(_ context.Context, parentID int64) ([]*Account, error) {
  49. var result []*Account
  50. for _, acc := range s.accounts {
  51. if acc.ParentAccountID != nil && *acc.ParentAccountID == parentID && acc.QuotaDimension == QuotaDimensionSpark {
  52. cp := *acc
  53. result = append(result, &cp)
  54. }
  55. }
  56. return result, nil
  57. }
  58. func (s *sparkShadowRepoStub) BindGroups(_ context.Context, accountID int64, groupIDs []int64) error {
  59. s.groupsOf[accountID] = append(s.groupsOf[accountID], groupIDs...)
  60. return nil
  61. }
  62. func (s *sparkShadowRepoStub) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) {
  63. var result []Account
  64. for accID, groups := range s.groupsOf {
  65. for _, gid := range groups {
  66. if gid == groupID {
  67. if acc, ok := s.accounts[accID]; ok {
  68. result = append(result, *acc)
  69. }
  70. break
  71. }
  72. }
  73. }
  74. return result, nil
  75. }
  76. // ListWithFilters は mockAccountRepoForGemini にないが AccountRepository が要求する。
  77. // 親の mockAccountRepoForGemini の nil 実装が継承されるため、ここでは省略可。
  78. // ── 追加 stub(AccountRepository に必要な残りのメソッド)──────────────────
  79. func (s *sparkShadowRepoStub) ExistsByID(_ context.Context, id int64) (bool, error) {
  80. _, ok := s.accounts[id]
  81. return ok, nil
  82. }
  83. func (s *sparkShadowRepoStub) Update(_ context.Context, account *Account) error {
  84. if _, ok := s.accounts[account.ID]; !ok {
  85. return ErrAccountNotFound
  86. }
  87. cp := *account
  88. s.accounts[account.ID] = &cp
  89. s.mockAccountRepoForGemini.accountsByID[account.ID] = &cp
  90. return nil
  91. }
  92. func (s *sparkShadowRepoStub) Delete(_ context.Context, id int64) error {
  93. delete(s.accounts, id)
  94. delete(s.mockAccountRepoForGemini.accountsByID, id)
  95. return nil
  96. }
  97. func (s *sparkShadowRepoStub) BatchUpdateLastUsed(_ context.Context, _ map[int64]time.Time) error {
  98. return nil
  99. }
  100. func (s *sparkShadowRepoStub) ListByGroup(_ context.Context, _ int64) ([]Account, error) {
  101. return nil, nil
  102. }
  103. func (s *sparkShadowRepoStub) ListWithFilters(_ context.Context, _ pagination.PaginationParams, _, _, _, _ string, _ int64, _ string) ([]Account, *pagination.PaginationResult, error) {
  104. return nil, nil, nil
  105. }
  106. // TestCreateShadow はメインのシナリオを検証する。
  107. //
  108. // Test 1 — 基本生成: ParentAccountID / QuotaDimension / 默认 spark model_mapping / 无 auth token / ProxyID 継承
  109. // Test 2 — 一母一影: 二度目の生成はエラー
  110. func TestCreateShadow(t *testing.T) {
  111. ctx := context.Background()
  112. repo := newSparkShadowRepoStub()
  113. svc := &adminServiceImpl{accountRepo: repo}
  114. proxyID := int64(7)
  115. parent := &Account{
  116. Name: "p",
  117. Platform: PlatformOpenAI,
  118. Type: AccountTypeOAuth,
  119. Status: StatusActive,
  120. ProxyID: &proxyID,
  121. Credentials: map[string]any{
  122. "refresh_token": "RT",
  123. "chatgpt_account_id": "org-x",
  124. },
  125. }
  126. require.NoError(t, repo.Create(ctx, parent))
  127. // Test 1: 基本生成
  128. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "p-spark", Priority: 50})
  129. require.NoError(t, err)
  130. require.NotNil(t, shadow)
  131. require.Equal(t, parent.ID, *shadow.ParentAccountID)
  132. require.Equal(t, QuotaDimensionSpark, shadow.QuotaDimension)
  133. require.Equal(t, defaultSparkShadowModelMapping(), shadow.Credentials["model_mapping"],
  134. "影子默认带 spark 恒等变体映射")
  135. require.Nil(t, shadow.Credentials["refresh_token"], "影子不得持有 auth token")
  136. require.Nil(t, shadow.Credentials["access_token"], "影子不得持有 auth token")
  137. require.Equal(t, parent.ProxyID, shadow.ProxyID)
  138. // Test 2: 一母一影 — 再作成は拒否
  139. _, err = svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "dup"})
  140. require.Error(t, err)
  141. }
  142. func TestCreateShadowInheritsParentEffectiveOpenAILongContextBillingValue(t *testing.T) {
  143. tests := []struct {
  144. name string
  145. parentExtra map[string]any
  146. want bool
  147. }{
  148. {name: "missing parent value defaults disabled", want: false},
  149. {name: "explicit parent opt-out is inherited", parentExtra: map[string]any{openAILongContextBillingEnabledKey: false}, want: false},
  150. }
  151. for _, tt := range tests {
  152. t.Run(tt.name, func(t *testing.T) {
  153. repo := newSparkShadowRepoStub()
  154. svc := &adminServiceImpl{accountRepo: repo}
  155. parent := &Account{
  156. Name: "parent",
  157. Platform: PlatformOpenAI,
  158. Type: AccountTypeOAuth,
  159. Status: StatusActive,
  160. Credentials: map[string]any{"access_token": "token"},
  161. Extra: tt.parentExtra,
  162. }
  163. require.NoError(t, repo.Create(context.Background(), parent))
  164. shadow, err := svc.CreateShadow(context.Background(), parent.ID, ShadowOptions{Name: "shadow"})
  165. require.NoError(t, err)
  166. require.Equal(t, tt.want, shadow.Extra[openAILongContextBillingEnabledKey])
  167. require.Equal(t, tt.want, shadow.IsOpenAILongContextBillingEnabled())
  168. })
  169. }
  170. }
  171. // TestCreateShadow_BindGroups は BindGroups の後置呼び出しを検証する。
  172. // 影子账号が指定グループに属し、ListSchedulableByGroupID で取得可能であること。
  173. func TestCreateShadow_BindGroups(t *testing.T) {
  174. ctx := context.Background()
  175. repo := newSparkShadowRepoStub()
  176. svc := &adminServiceImpl{accountRepo: repo}
  177. parent := &Account{
  178. Name: "parent",
  179. Platform: PlatformOpenAI,
  180. Type: AccountTypeOAuth,
  181. Status: StatusActive,
  182. Credentials: map[string]any{
  183. "chatgpt_account_id": "org-y",
  184. },
  185. }
  186. require.NoError(t, repo.Create(ctx, parent))
  187. const testGroupID = int64(42)
  188. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{
  189. Name: "p-spark",
  190. GroupIDs: []int64{testGroupID},
  191. })
  192. require.NoError(t, err)
  193. require.NotNil(t, shadow)
  194. require.Equal(t, []int64{testGroupID}, shadow.GroupIDs, "CreateShadow should backfill GroupIDs into the returned shadow")
  195. accounts, err := repo.ListSchedulableByGroupID(ctx, testGroupID)
  196. require.NoError(t, err)
  197. require.Len(t, accounts, 1)
  198. require.Equal(t, shadow.ID, accounts[0].ID)
  199. }
  200. // TestDeleteAccount_CascadeToShadow verifies that deleting a parent account also
  201. // deletes its spark shadow account.
  202. // TestCreateShadow_InheritsParentConcurrency 验证外审 F3:未指定并发时
  203. // 影子继承母账号并发,避免 Concurrency=0 被限流器当作"无限并发"。
  204. func TestCreateShadow_InheritsParentConcurrency(t *testing.T) {
  205. ctx := context.Background()
  206. t.Run("unspecified_inherits_parent", func(t *testing.T) {
  207. repo := newSparkShadowRepoStub()
  208. svc := &adminServiceImpl{accountRepo: repo}
  209. parent := &Account{
  210. Name: "conc-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  211. Status: StatusActive, Concurrency: 3,
  212. Credentials: map[string]any{"chatgpt_account_id": "org-c"},
  213. }
  214. require.NoError(t, repo.Create(ctx, parent))
  215. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "conc-shadow"})
  216. require.NoError(t, err)
  217. require.Equal(t, 3, shadow.Concurrency, "未指定并发应继承母账号(非 0=无限)")
  218. require.Equal(t, 3, repo.accounts[shadow.ID].Concurrency)
  219. })
  220. t.Run("explicit_positive_kept", func(t *testing.T) {
  221. repo := newSparkShadowRepoStub()
  222. svc := &adminServiceImpl{accountRepo: repo}
  223. parent := &Account{
  224. Name: "conc-parent2", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  225. Status: StatusActive, Concurrency: 3,
  226. Credentials: map[string]any{"chatgpt_account_id": "org-c2"},
  227. }
  228. require.NoError(t, repo.Create(ctx, parent))
  229. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "conc-shadow2", Concurrency: 2})
  230. require.NoError(t, err)
  231. require.Equal(t, 2, shadow.Concurrency, "显式正并发应保留")
  232. })
  233. }
  234. // TestCreateShadow_InheritsParentPriorityWhenOmitted 验证外审第5轮 P1:未指定优先级时
  235. // 影子继承母账号 priority,而非直写 0 抢到最高调度优先级(repo SetPriority 绕过 ent 默认 50,
  236. // 调度比较数值越小越优先;前端一键创建只传 name 即触发该路径)。
  237. func TestCreateShadow_InheritsParentPriorityWhenOmitted(t *testing.T) {
  238. ctx := context.Background()
  239. t.Run("unspecified_inherits_parent", func(t *testing.T) {
  240. repo := newSparkShadowRepoStub()
  241. svc := &adminServiceImpl{accountRepo: repo}
  242. parent := &Account{
  243. Name: "prio-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  244. Status: StatusActive, Priority: 30,
  245. Credentials: map[string]any{"chatgpt_account_id": "org-p"},
  246. }
  247. require.NoError(t, repo.Create(ctx, parent))
  248. // 模拟前端一键创建:只传 name,priority 省略=0。
  249. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "prio-shadow"})
  250. require.NoError(t, err)
  251. require.Equal(t, 30, shadow.Priority, "未指定优先级应继承母账号(而非 0=最高优先级)")
  252. require.Equal(t, 30, repo.accounts[shadow.ID].Priority)
  253. })
  254. t.Run("explicit_positive_kept", func(t *testing.T) {
  255. repo := newSparkShadowRepoStub()
  256. svc := &adminServiceImpl{accountRepo: repo}
  257. parent := &Account{
  258. Name: "prio-parent2", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  259. Status: StatusActive, Priority: 30,
  260. Credentials: map[string]any{"chatgpt_account_id": "org-p2"},
  261. }
  262. require.NoError(t, repo.Create(ctx, parent))
  263. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "prio-shadow2", Priority: 7})
  264. require.NoError(t, err)
  265. require.Equal(t, 7, shadow.Priority, "显式正优先级应保留")
  266. })
  267. }
  268. // TestPersistAccountCredentials_SkipsShadow 验证外审第6轮 P1:凭据写入唯一汇聚点
  269. // persistAccountCredentials 对 spark 影子早返 no-op,任何上游路径都无法把凭据落到影子行。
  270. func TestPersistAccountCredentials_SkipsShadow(t *testing.T) {
  271. ctx := context.Background()
  272. repo := newSparkShadowRepoStub()
  273. parentID := int64(1)
  274. shadow := &Account{
  275. Name: "shadow", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  276. Status: StatusActive, Credentials: map[string]any{},
  277. ParentAccountID: &parentID, QuotaDimension: QuotaDimensionSpark,
  278. }
  279. require.NoError(t, repo.Create(ctx, shadow)) // 回填 shadow.ID,使「漏判则 Update 成功」可被检出
  280. err := persistAccountCredentials(ctx, repo, shadow, map[string]any{"access_token": "LEAK", "refresh_token": "LEAK"})
  281. require.NoError(t, err)
  282. require.Empty(t, shadow.Credentials, "影子凭据不可被写入(传入对象)")
  283. require.Empty(t, repo.accounts[shadow.ID].Credentials, "影子凭据不可被写入(仓储)")
  284. }
  285. // TestResolveCredentialAccount_RejectsParentShadow 验证外审第6轮 P2 防御:畸形数据/手工 DB
  286. // 写出的「影子→影子」链,凭据解析必须 fail-closed 而非停在无凭据的一级影子。
  287. func TestResolveCredentialAccount_RejectsParentShadow(t *testing.T) {
  288. ctx := context.Background()
  289. repo := newSparkShadowRepoStub()
  290. grandparent := &Account{
  291. Name: "gp", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive,
  292. Credentials: map[string]any{"refresh_token": "RT"},
  293. }
  294. require.NoError(t, repo.Create(ctx, grandparent))
  295. // parent 本身是影子(非法二级结构)
  296. parentShadow := &Account{
  297. Name: "parent-shadow", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive,
  298. Credentials: map[string]any{}, ParentAccountID: &grandparent.ID, QuotaDimension: QuotaDimensionSpark,
  299. }
  300. require.NoError(t, repo.Create(ctx, parentShadow))
  301. child := &Account{
  302. Name: "child-shadow", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive,
  303. Credentials: map[string]any{}, ParentAccountID: &parentShadow.ID, QuotaDimension: QuotaDimensionSpark,
  304. }
  305. require.NoError(t, repo.Create(ctx, child))
  306. _, err := resolveCredentialAccount(ctx, repo, child)
  307. require.Error(t, err, "父账号本身是影子时凭据解析应拒绝(fail-closed)")
  308. }
  309. // TestPersistOpenAI429PlanType_SkipsShadow 验证外审第7轮 P1:429 plan_type 同步走 BulkUpdate 直写
  310. // (不经 persistAccountCredentials),必须对影子早返,否则会把 plan_type 写进影子 credentials。
  311. func TestPersistOpenAI429PlanType_SkipsShadow(t *testing.T) {
  312. ctx := context.Background()
  313. body := []byte(`{"error":{"type":"usage_limit_reached","plan_type":"pro"}}`)
  314. parentID := int64(1)
  315. t.Run("shadow_skipped", func(t *testing.T) {
  316. repo := newSparkShadowRepoStub()
  317. shadow := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{}, ParentAccountID: &parentID}
  318. persistOpenAI429PlanType(ctx, repo, shadow, body)
  319. require.Empty(t, shadow.Credentials, "影子不可被写入 plan_type 凭据")
  320. })
  321. t.Run("normal_account_writes", func(t *testing.T) {
  322. repo := newSparkShadowRepoStub()
  323. normal := &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{}}
  324. persistOpenAI429PlanType(ctx, repo, normal, body)
  325. require.Equal(t, "pro", normal.Credentials["plan_type"], "普通账号应写入 plan_type(反向对照,证明 body 有效、写路径通)")
  326. })
  327. }
  328. // updateExtraSpyRepo 记录 UpdateExtra 是否被调用,用于验证影子 codex_* 快照守卫。
  329. type updateExtraSpyRepo struct {
  330. *sparkShadowRepoStub
  331. updateExtraCalled bool
  332. }
  333. func (r *updateExtraSpyRepo) UpdateExtra(_ context.Context, _ int64, _ map[string]any) error {
  334. r.updateExtraCalled = true
  335. return nil
  336. }
  337. // TestPersistOpenAICodexSnapshot_SkipsShadow 验证外审第7轮 P1:影子 codex_* 仅由 QueryUsage
  338. // (/wham/usage bengalfox)更新,不能被 429 路径的 x-codex-* 全局头快照污染。
  339. func TestPersistOpenAICodexSnapshot_SkipsShadow(t *testing.T) {
  340. headers := http.Header{}
  341. headers.Set("x-codex-primary-used-percent", "50")
  342. parentID := int64(1)
  343. t.Run("shadow_skipped", func(t *testing.T) {
  344. spy := &updateExtraSpyRepo{sparkShadowRepoStub: newSparkShadowRepoStub()}
  345. s := &RateLimitService{accountRepo: spy}
  346. shadow := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parentID}
  347. s.persistOpenAICodexSnapshot(context.Background(), shadow, headers)
  348. require.False(t, spy.updateExtraCalled, "影子不应写 codex_* 头快照")
  349. })
  350. t.Run("normal_account_writes", func(t *testing.T) {
  351. spy := &updateExtraSpyRepo{sparkShadowRepoStub: newSparkShadowRepoStub()}
  352. s := &RateLimitService{accountRepo: spy}
  353. normal := &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  354. s.persistOpenAICodexSnapshot(context.Background(), normal, headers)
  355. require.True(t, spy.updateExtraCalled, "普通账号应写 codex_* 头快照(反向对照)")
  356. })
  357. }
  358. // TestResetAccountQuota_RejectsShadow 验证外审第7轮 P2:通用 reset-quota 对影子明确 400 拒绝
  359. // (影子不持自有配额,语义不一致),且母账号仍可正常重置。
  360. func TestResetAccountQuota_RejectsShadow(t *testing.T) {
  361. ctx := context.Background()
  362. repo := newSparkShadowRepoStub()
  363. svc := &adminServiceImpl{accountRepo: repo}
  364. parent := &Account{
  365. Name: "rq-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive,
  366. Credentials: map[string]any{"chatgpt_account_id": "o"},
  367. }
  368. require.NoError(t, repo.Create(ctx, parent))
  369. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "rq-shadow"})
  370. require.NoError(t, err)
  371. err = svc.ResetAccountQuota(ctx, shadow.ID)
  372. require.Error(t, err)
  373. require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "影子 reset-quota 应 400")
  374. require.NoError(t, svc.ResetAccountQuota(ctx, parent.ID), "母账号 reset-quota 应放行")
  375. }
  376. // sparkShadowGroupRepoStub 嵌入 groupRepoStub(其余方法 panic),仅覆写
  377. // ListActiveByPlatform 以供 F4 默认绑组测试。
  378. type sparkShadowGroupRepoStub struct {
  379. groupRepoStub
  380. groups []Group
  381. }
  382. func (s *sparkShadowGroupRepoStub) ListActiveByPlatform(_ context.Context, _ string) ([]Group, error) {
  383. return s.groups, nil
  384. }
  385. // TestCreateShadow_DefaultGroupBinding 验证外审 F4:未指定 group_ids 时
  386. // 影子回落绑定 openai-default 组(否则无组、组内路由选不到)。
  387. func TestCreateShadow_DefaultGroupBinding(t *testing.T) {
  388. ctx := context.Background()
  389. repo := newSparkShadowRepoStub()
  390. groupRepo := &sparkShadowGroupRepoStub{
  391. groups: []Group{
  392. {ID: 99, Name: PlatformOpenAI + "-default"},
  393. {ID: 7, Name: "some-other-group"},
  394. },
  395. }
  396. svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo}
  397. parent := &Account{
  398. Name: "grp-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  399. Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "org-g"},
  400. }
  401. require.NoError(t, repo.Create(ctx, parent))
  402. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "grp-shadow"})
  403. require.NoError(t, err)
  404. require.Equal(t, []int64{99}, repo.groupsOf[shadow.ID], "未指定分组应回落绑定 openai-default(id=99)")
  405. }
  406. // TestCreateShadow_InheritsParentGroups 验证外审 G1:未指定 group_ids 时
  407. // 影子继承母账号当前分组(而非仅 openai-default),以便母在自定义组时影子也可路由。
  408. func TestCreateShadow_InheritsParentGroups(t *testing.T) {
  409. ctx := context.Background()
  410. repo := newSparkShadowRepoStub()
  411. // groupRepo 故意提供 openai-default,以证明「继承母分组」优先于「回落 openai-default」。
  412. groupRepo := &sparkShadowGroupRepoStub{groups: []Group{{ID: 99, Name: PlatformOpenAI + "-default"}}}
  413. svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo}
  414. parent := &Account{
  415. Name: "grp-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  416. Status: StatusActive, GroupIDs: []int64{11, 22},
  417. Credentials: map[string]any{"chatgpt_account_id": "org-grp"},
  418. }
  419. require.NoError(t, repo.Create(ctx, parent))
  420. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "grp-shadow"})
  421. require.NoError(t, err)
  422. require.Equal(t, []int64{11, 22}, repo.groupsOf[shadow.ID], "未指定分组应继承母账号分组,而非 openai-default")
  423. }
  424. // TestCreateShadow_RejectsShadowAsParent 验证外审 G6:不允许把影子当母创建二级影子。
  425. func TestCreateShadow_RejectsShadowAsParent(t *testing.T) {
  426. ctx := context.Background()
  427. repo := newSparkShadowRepoStub()
  428. svc := &adminServiceImpl{accountRepo: repo}
  429. parent := &Account{
  430. Name: "real-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  431. Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "org-x"},
  432. }
  433. require.NoError(t, repo.Create(ctx, parent))
  434. firstShadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "first-shadow"})
  435. require.NoError(t, err)
  436. // 把一级影子当母 → 必须被拒(400)。
  437. _, err = svc.CreateShadow(ctx, firstShadow.ID, ShadowOptions{Name: "second-shadow"})
  438. require.Error(t, err)
  439. require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "影子当母应返回 400")
  440. }
  441. // TestCreateShadow_StructuredErrors 验证外审 G3:可预期业务错误返回结构化 4xx 而非 500。
  442. func TestCreateShadow_StructuredErrors(t *testing.T) {
  443. ctx := context.Background()
  444. t.Run("non_oauth_parent_400", func(t *testing.T) {
  445. repo := newSparkShadowRepoStub()
  446. svc := &adminServiceImpl{accountRepo: repo}
  447. parent := &Account{Name: "apikey-parent", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive}
  448. require.NoError(t, repo.Create(ctx, parent))
  449. _, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"})
  450. require.Error(t, err)
  451. require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "非 OAuth 母账号应 400")
  452. })
  453. t.Run("duplicate_409", func(t *testing.T) {
  454. repo := newSparkShadowRepoStub()
  455. svc := &adminServiceImpl{accountRepo: repo}
  456. parent := &Account{Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"}}
  457. require.NoError(t, repo.Create(ctx, parent))
  458. _, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s1"})
  459. require.NoError(t, err)
  460. _, err = svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s2"})
  461. require.Error(t, err)
  462. require.Equal(t, http.StatusConflict, infraerrors.Code(err), "重复创建应 409")
  463. })
  464. }
  465. // TestUpdateAccount_RejectsTypeChangeOnShadow 验证外审 G7:影子 type 不可被普通更新改坏。
  466. func TestUpdateAccount_RejectsTypeChangeOnShadow(t *testing.T) {
  467. ctx := context.Background()
  468. repo := newSparkShadowRepoStub()
  469. svc := &adminServiceImpl{accountRepo: repo}
  470. parent := &Account{
  471. Name: "type-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  472. Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "org-t"},
  473. }
  474. require.NoError(t, repo.Create(ctx, parent))
  475. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "type-shadow"})
  476. require.NoError(t, err)
  477. // 试图把影子 type 改成 apikey → 必须被拒(400)。
  478. _, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{Type: AccountTypeAPIKey})
  479. require.Error(t, err)
  480. require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "改影子 type 应 400")
  481. require.Equal(t, AccountTypeOAuth, repo.accounts[shadow.ID].Type, "影子 type 必须保持 oauth")
  482. // 传入相同 type(oauth)为 no-op,应允许。
  483. _, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{Type: AccountTypeOAuth})
  484. require.NoError(t, err, "传入相同 type 应允许")
  485. }
  486. // TestBulkUpdateAccounts_RejectsCredentialWriteToShadow 验证外审 G5:批量更新携带凭据时
  487. // 目标含影子必须被拒(与单账号 UpdateAccount 守卫对齐,堵住 bulk 绕过)。
  488. func TestBulkUpdateAccounts_RejectsCredentialWriteToShadow(t *testing.T) {
  489. ctx := context.Background()
  490. repo := newSparkShadowRepoStub()
  491. svc := &adminServiceImpl{accountRepo: repo}
  492. parent := &Account{
  493. Name: "bulk-parent", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  494. Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "org-b", "access_token": "t"},
  495. }
  496. require.NoError(t, repo.Create(ctx, parent))
  497. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "bulk-shadow"})
  498. require.NoError(t, err)
  499. _, err = svc.BulkUpdateAccounts(ctx, &BulkUpdateAccountsInput{
  500. AccountIDs: []int64{shadow.ID},
  501. Credentials: map[string]any{"access_token": "leaked"},
  502. })
  503. require.Error(t, err, "批量给影子写凭据必须被拒")
  504. require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "应 400")
  505. // Credentials 允许持有 model_mapping(CreateShadow 写入的默认值),该不变量只约束
  506. // 鉴权凭据不可泄露到影子——不能整体断言 Credentials 为空。
  507. require.Empty(t, repo.accounts[shadow.ID].GetOpenAIAccessToken(), "影子 access_token 必须保持为空 —— 批量写入未生效")
  508. }
  509. func TestDeleteAccount_CascadeToShadow(t *testing.T) {
  510. ctx := context.Background()
  511. repo := newSparkShadowRepoStub()
  512. svc := &adminServiceImpl{accountRepo: repo}
  513. parent := &Account{
  514. Name: "cascade-parent",
  515. Platform: PlatformOpenAI,
  516. Type: AccountTypeOAuth,
  517. Status: StatusActive,
  518. Credentials: map[string]any{"chatgpt_account_id": "org-cascade"},
  519. }
  520. require.NoError(t, repo.Create(ctx, parent))
  521. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "cascade-shadow"})
  522. require.NoError(t, err)
  523. shadowID := shadow.ID
  524. // Both accounts exist before delete.
  525. _, ok := repo.accounts[parent.ID]
  526. require.True(t, ok)
  527. _, ok = repo.accounts[shadowID]
  528. require.True(t, ok)
  529. require.NoError(t, svc.DeleteAccount(ctx, parent.ID))
  530. // Parent is gone.
  531. _, ok = repo.accounts[parent.ID]
  532. require.False(t, ok, "parent account should be deleted")
  533. // Shadow is also gone (cascade).
  534. _, ok = repo.accounts[shadowID]
  535. require.False(t, ok, "shadow account should be cascade-deleted")
  536. }
  537. // TestUpdateAccount_PropagatesProxyToShadow verifies that updating a parent
  538. // account's ProxyID propagates the new value to its spark shadow.
  539. func TestUpdateAccount_PropagatesProxyToShadow(t *testing.T) {
  540. ctx := context.Background()
  541. repo := newSparkShadowRepoStub()
  542. svc := &adminServiceImpl{accountRepo: repo}
  543. oldProxy := int64(7)
  544. parent := &Account{
  545. Name: "proxy-parent",
  546. Platform: PlatformOpenAI,
  547. Type: AccountTypeOAuth,
  548. Status: StatusActive,
  549. ProxyID: &oldProxy,
  550. Credentials: map[string]any{"chatgpt_account_id": "org-proxy"},
  551. }
  552. require.NoError(t, repo.Create(ctx, parent))
  553. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "proxy-shadow"})
  554. require.NoError(t, err)
  555. shadowID := shadow.ID
  556. // Update parent's ProxyID.
  557. newProxy := int64(42)
  558. _, err = svc.UpdateAccount(ctx, parent.ID, &UpdateAccountInput{ProxyID: &newProxy})
  559. require.NoError(t, err)
  560. // Shadow must carry the new ProxyID.
  561. storedShadow, ok := repo.accounts[shadowID]
  562. require.True(t, ok)
  563. require.NotNil(t, storedShadow.ProxyID)
  564. require.Equal(t, newProxy, *storedShadow.ProxyID)
  565. }
  566. // TestUpdateAccount_RejectsCredentialWriteToShadow 验证安全不变量「影子绝不持有鉴权凭据」
  567. // 在通用更新路径(UpdateAccount,被 edit/re-auth/refresh/batch 共用)上也被守住:
  568. // 对影子写入 access_token/refresh_token 必须被拒绝,且影子的 access_token/refresh_token
  569. // 保持为空(Credentials 本身允许持有 CreateShadow 写入的 model_mapping,故不能断言整体为空)。
  570. func TestUpdateAccount_RejectsCredentialWriteToShadow(t *testing.T) {
  571. ctx := context.Background()
  572. repo := newSparkShadowRepoStub()
  573. svc := &adminServiceImpl{accountRepo: repo}
  574. parent := &Account{
  575. Name: "cred-parent",
  576. Platform: PlatformOpenAI,
  577. Type: AccountTypeOAuth,
  578. Status: StatusActive,
  579. Credentials: map[string]any{"access_token": "parent-secret", "refresh_token": "parent-rt"},
  580. }
  581. require.NoError(t, repo.Create(ctx, parent))
  582. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "cred-shadow"})
  583. require.NoError(t, err)
  584. require.Empty(t, shadow.GetOpenAIAccessToken(), "前提:影子创建后不持有 access_token")
  585. require.Empty(t, shadow.GetOpenAIRefreshToken(), "前提:影子创建后不持有 refresh_token")
  586. // 试图给影子写入凭据 → 必须被拒绝。
  587. _, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{
  588. Credentials: map[string]any{"access_token": "leaked", "refresh_token": "leaked-rt"},
  589. })
  590. require.Error(t, err, "对影子写入凭据必须被拒绝")
  591. // 结构化 4xx(非裸 error→500)。
  592. require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "应映射为 400 而非 500")
  593. // 影子的 access_token/refresh_token 仍为空 —— 凭据未被写入。
  594. storedShadow, ok := repo.accounts[shadow.ID]
  595. require.True(t, ok)
  596. require.Empty(t, storedShadow.GetOpenAIAccessToken(), "影子 access_token 必须保持为空 —— 凭据未被写入")
  597. require.Empty(t, storedShadow.GetOpenAIRefreshToken(), "影子 refresh_token 必须保持为空 —— 凭据未被写入")
  598. // 对照:不带凭据的字段更新(如 Priority)仍应成功。
  599. newPriority := 5
  600. _, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{Priority: &newPriority})
  601. require.NoError(t, err, "影子的非凭据字段更新应正常")
  602. }
  603. // TestBulkUpdateAccounts_PropagatesProxyToShadow verifies that bulk-updating
  604. // accounts' ProxyID propagates the new value to each account's spark shadow.
  605. func TestBulkUpdateAccounts_PropagatesProxyToShadow(t *testing.T) {
  606. ctx := context.Background()
  607. repo := newSparkShadowRepoStub()
  608. svc := &adminServiceImpl{accountRepo: repo}
  609. oldProxy := int64(7)
  610. parent := &Account{
  611. Name: "bulk-parent",
  612. Platform: PlatformOpenAI,
  613. Type: AccountTypeOAuth,
  614. Status: StatusActive,
  615. ProxyID: &oldProxy,
  616. Credentials: map[string]any{"chatgpt_account_id": "org-bulk"},
  617. }
  618. require.NoError(t, repo.Create(ctx, parent))
  619. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "bulk-shadow"})
  620. require.NoError(t, err)
  621. shadowID := shadow.ID
  622. // Bulk update parent's ProxyID.
  623. newProxy := int64(99)
  624. _, err = svc.BulkUpdateAccounts(ctx, &BulkUpdateAccountsInput{
  625. AccountIDs: []int64{parent.ID},
  626. ProxyID: &newProxy,
  627. })
  628. require.NoError(t, err)
  629. // Shadow must carry the new ProxyID.
  630. storedShadow, ok := repo.accounts[shadowID]
  631. require.True(t, ok)
  632. require.NotNil(t, storedShadow.ProxyID)
  633. require.Equal(t, newProxy, *storedShadow.ProxyID)
  634. }
  635. // ── 外审 P1/P2 加固:专用测试桩 ───────────────────────────────────────────
  636. // raceCreateRepoStub 模拟并发竞态:对影子的 Create 撞一母一影唯一索引(返回错误),
  637. // 且复查时另一并发请求的影子已存在 → CreateShadow 应映射为结构化 409(外审 A/P1)。
  638. type raceCreateRepoStub struct {
  639. *sparkShadowRepoStub
  640. }
  641. func (s *raceCreateRepoStub) Create(ctx context.Context, account *Account) error {
  642. if account.ParentAccountID != nil {
  643. // 模拟另一并发请求已抢先建成影子:注入底层 map,本次 Create 撞唯一索引失败。
  644. s.sparkShadowRepoStub.nextID++
  645. phantom := *account
  646. phantom.ID = s.sparkShadowRepoStub.nextID
  647. s.sparkShadowRepoStub.accounts[phantom.ID] = &phantom
  648. return errors.New(`duplicate key value violates unique constraint "uq_accounts_spark_shadow_per_parent"`)
  649. }
  650. return s.sparkShadowRepoStub.Create(ctx, account)
  651. }
  652. // bindFailRepoStub 让 BindGroups 失败,用于验证绑组失败时补偿删除刚建的影子(外审 C/P1)。
  653. type bindFailRepoStub struct {
  654. *sparkShadowRepoStub
  655. }
  656. func (s *bindFailRepoStub) BindGroups(_ context.Context, _ int64, _ []int64) error {
  657. return errors.New("simulated bind failure")
  658. }
  659. // sparkShadowValidatingGroupRepoStub 实现 groupExistenceBatchReader(ExistsByIDs),
  660. // 使 validateGroupIDsExist 走批量存在性校验路径。
  661. type sparkShadowValidatingGroupRepoStub struct {
  662. groupRepoStub
  663. existing map[int64]bool
  664. }
  665. func (s *sparkShadowValidatingGroupRepoStub) ExistsByIDs(_ context.Context, ids []int64) (map[int64]bool, error) {
  666. out := make(map[int64]bool, len(ids))
  667. for _, id := range ids {
  668. out[id] = s.existing[id]
  669. }
  670. return out, nil
  671. }
  672. // TestCreateShadow_DefaultsNameFromParent 验证外审 E/P2:空 name 不应 500,
  673. // 而是默认 "<母账号名> (Spark)"。
  674. func TestCreateShadow_DefaultsNameFromParent(t *testing.T) {
  675. ctx := context.Background()
  676. repo := newSparkShadowRepoStub()
  677. svc := &adminServiceImpl{accountRepo: repo}
  678. parent := &Account{
  679. Name: "mum", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  680. Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"},
  681. }
  682. require.NoError(t, repo.Create(ctx, parent))
  683. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: " "})
  684. require.NoError(t, err, "空/空白 name 不应 500,应默认命名")
  685. require.Equal(t, "mum (Spark)", shadow.Name)
  686. }
  687. // TestCreateShadow_ConcurrentCreateReturns409 验证外审 A/P1:并发竞态下预查放行后
  688. // Create 撞唯一索引,应映射结构化 409 而非裸 500。
  689. func TestCreateShadow_ConcurrentCreateReturns409(t *testing.T) {
  690. ctx := context.Background()
  691. base := newSparkShadowRepoStub()
  692. repo := &raceCreateRepoStub{sparkShadowRepoStub: base}
  693. svc := &adminServiceImpl{accountRepo: repo}
  694. parent := &Account{
  695. Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  696. Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"},
  697. }
  698. require.NoError(t, base.Create(ctx, parent))
  699. _, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"})
  700. require.Error(t, err)
  701. require.Equal(t, http.StatusConflict, infraerrors.Code(err), "并发竞态撞唯一索引应映射 409 而非 500")
  702. }
  703. // TestCreateShadow_InvalidGroupRejectedNoOrphan 验证外审 C/P1:显式无效分组应在
  704. // 创建前被拒,不留孤儿影子。
  705. func TestCreateShadow_InvalidGroupRejectedNoOrphan(t *testing.T) {
  706. ctx := context.Background()
  707. repo := newSparkShadowRepoStub()
  708. groupRepo := &sparkShadowValidatingGroupRepoStub{existing: map[int64]bool{7: true}}
  709. svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo}
  710. parent := &Account{
  711. Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  712. Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"},
  713. }
  714. require.NoError(t, repo.Create(ctx, parent))
  715. _, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s", GroupIDs: []int64{999}})
  716. require.Error(t, err, "无效分组应在创建前被拒")
  717. shadows, qerr := repo.ListShadowsByParent(ctx, parent.ID)
  718. require.NoError(t, qerr)
  719. require.Empty(t, shadows, "无效分组应在创建前被拒,不应建出影子")
  720. }
  721. // TestCreateShadow_BindFailureRollsBackShadow 验证外审 C/P1:绑组失败时补偿删除
  722. // 刚建的影子,不留孤儿(否则一母一影唯一索引会挡住重试)。
  723. func TestCreateShadow_BindFailureRollsBackShadow(t *testing.T) {
  724. ctx := context.Background()
  725. base := newSparkShadowRepoStub()
  726. repo := &bindFailRepoStub{sparkShadowRepoStub: base}
  727. groupRepo := &sparkShadowValidatingGroupRepoStub{existing: map[int64]bool{7: true}}
  728. svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo}
  729. parent := &Account{
  730. Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  731. Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"},
  732. }
  733. require.NoError(t, base.Create(ctx, parent))
  734. _, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s", GroupIDs: []int64{7}})
  735. require.Error(t, err, "绑组失败应返回错误")
  736. shadows, qerr := base.ListShadowsByParent(ctx, parent.ID)
  737. require.NoError(t, qerr)
  738. require.Empty(t, shadows, "绑组失败后应补偿删除影子,不留孤儿")
  739. }
  740. // TestUpdateAccount_RejectsParentTypeChangeWithShadow 验证外审 D/P1:母账号有 spark 影子时,
  741. // 不能把 type 改出 OpenAI OAuth(否则影子被调度后透传凭据解析必失败)。
  742. func TestUpdateAccount_RejectsParentTypeChangeWithShadow(t *testing.T) {
  743. ctx := context.Background()
  744. repo := newSparkShadowRepoStub()
  745. svc := &adminServiceImpl{accountRepo: repo}
  746. parent := &Account{
  747. Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  748. Status: StatusActive, Credentials: map[string]any{"chatgpt_account_id": "o"},
  749. }
  750. require.NoError(t, repo.Create(ctx, parent))
  751. _, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"})
  752. require.NoError(t, err)
  753. _, err = svc.UpdateAccount(ctx, parent.ID, &UpdateAccountInput{Type: AccountTypeAPIKey})
  754. require.Error(t, err)
  755. require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "母账号有影子时改 type 出 oauth 应 400")
  756. require.Equal(t, AccountTypeOAuth, repo.accounts[parent.ID].Type, "母账号 type 必须保持 oauth")
  757. // 对照:把 type 设为相同 oauth(no-op)应允许。
  758. _, err = svc.UpdateAccount(ctx, parent.ID, &UpdateAccountInput{Type: AccountTypeOAuth})
  759. require.NoError(t, err, "传入相同 type(no-op)应允许")
  760. }
  761. // TestUpdateAccount_IgnoresProxyChangeOnShadow 验证外审 B/P1:影子 proxy 恒继承母账号,
  762. // 普通更新不得独立改动。
  763. func TestUpdateAccount_IgnoresProxyChangeOnShadow(t *testing.T) {
  764. ctx := context.Background()
  765. repo := newSparkShadowRepoStub()
  766. svc := &adminServiceImpl{accountRepo: repo}
  767. parentProxy := int64(7)
  768. parent := &Account{
  769. Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  770. Status: StatusActive, ProxyID: &parentProxy,
  771. Credentials: map[string]any{"chatgpt_account_id": "o"},
  772. }
  773. require.NoError(t, repo.Create(ctx, parent))
  774. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"})
  775. require.NoError(t, err)
  776. require.NotNil(t, repo.accounts[shadow.ID].ProxyID)
  777. require.Equal(t, parentProxy, *repo.accounts[shadow.ID].ProxyID, "前提:影子继承母 proxy=7")
  778. newProxy := int64(42)
  779. _, err = svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{ProxyID: &newProxy})
  780. require.NoError(t, err, "影子的非 proxy 字段更新仍应成功")
  781. require.NotNil(t, repo.accounts[shadow.ID].ProxyID)
  782. require.Equal(t, parentProxy, *repo.accounts[shadow.ID].ProxyID, "影子 proxy 不应被独立改动,恒继承母账号")
  783. }
  784. func TestUpdateAccount_ShadowAllowsModelMappingAndGroupUpdate(t *testing.T) {
  785. ctx := context.Background()
  786. repo := newSparkShadowRepoStub()
  787. groupRepo := &sparkShadowValidatingGroupRepoStub{existing: map[int64]bool{7: true}}
  788. svc := &adminServiceImpl{accountRepo: repo, groupRepo: groupRepo}
  789. parentID := int64(1)
  790. parent := &Account{
  791. ID: parentID,
  792. Name: "p",
  793. Platform: PlatformOpenAI,
  794. Type: AccountTypeOAuth,
  795. Status: StatusActive,
  796. Credentials: map[string]any{
  797. "access_token": "parent-token",
  798. "chatgpt_account_id": "org-parent",
  799. },
  800. }
  801. require.NoError(t, repo.Create(ctx, parent))
  802. shadow := &Account{
  803. Name: "s",
  804. Platform: PlatformOpenAI,
  805. Type: AccountTypeOAuth,
  806. Status: StatusActive,
  807. ParentAccountID: &parentID,
  808. QuotaDimension: QuotaDimensionSpark,
  809. Credentials: map[string]any{},
  810. }
  811. require.NoError(t, repo.Create(ctx, shadow))
  812. groupIDs := []int64{7}
  813. updated, err := svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{
  814. Credentials: map[string]any{
  815. "model_mapping": map[string]any{
  816. "gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
  817. },
  818. },
  819. GroupIDs: &groupIDs,
  820. })
  821. require.NoError(t, err)
  822. require.Equal(t, []int64{7}, repo.groupsOf[shadow.ID])
  823. require.Equal(t, map[string]any{"gpt-5.3-codex-spark": "gpt-5.3-codex-spark"}, updated.Credentials["model_mapping"])
  824. require.Empty(t, updated.GetOpenAIAccessToken(), "影子账号不可持有母账号 access_token")
  825. }
  826. func TestUpdateAccount_ShadowEmptyCredentialsClearsModelMapping(t *testing.T) {
  827. ctx := context.Background()
  828. repo := newSparkShadowRepoStub()
  829. svc := &adminServiceImpl{accountRepo: repo}
  830. parentID := int64(1)
  831. shadow := &Account{
  832. Name: "s",
  833. Platform: PlatformOpenAI,
  834. Type: AccountTypeOAuth,
  835. Status: StatusActive,
  836. ParentAccountID: &parentID,
  837. QuotaDimension: QuotaDimensionSpark,
  838. Credentials: map[string]any{
  839. "model_mapping": map[string]any{
  840. "gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
  841. },
  842. },
  843. }
  844. require.NoError(t, repo.Create(ctx, shadow))
  845. updated, err := svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{
  846. Credentials: map[string]any{},
  847. })
  848. require.NoError(t, err)
  849. require.Empty(t, updated.Credentials)
  850. require.Empty(t, repo.accounts[shadow.ID].Credentials)
  851. }
  852. func TestUpdateAccount_ShadowRejectsAuthCredentials(t *testing.T) {
  853. ctx := context.Background()
  854. repo := newSparkShadowRepoStub()
  855. svc := &adminServiceImpl{accountRepo: repo}
  856. parentID := int64(1)
  857. shadow := &Account{
  858. Name: "s",
  859. Platform: PlatformOpenAI,
  860. Type: AccountTypeOAuth,
  861. Status: StatusActive,
  862. ParentAccountID: &parentID,
  863. QuotaDimension: QuotaDimensionSpark,
  864. Credentials: map[string]any{},
  865. }
  866. require.NoError(t, repo.Create(ctx, shadow))
  867. _, err := svc.UpdateAccount(ctx, shadow.ID, &UpdateAccountInput{
  868. Credentials: map[string]any{"access_token": "leak"},
  869. })
  870. require.Error(t, err)
  871. require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
  872. require.Empty(t, repo.accounts[shadow.ID].Credentials)
  873. }
  874. // TestBulkUpdateAccounts_RejectsProxyChangeOnShadow 验证外审第4轮 P1:批量更新携带 proxy 且
  875. // 目标含影子必须被拒(与单账号 UpdateAccount 守卫对齐,堵住 bulk 绕过"proxy 恒继承母账号")。
  876. func TestBulkUpdateAccounts_RejectsProxyChangeOnShadow(t *testing.T) {
  877. ctx := context.Background()
  878. repo := newSparkShadowRepoStub()
  879. svc := &adminServiceImpl{accountRepo: repo}
  880. parentProxy := int64(7)
  881. parent := &Account{
  882. Name: "p", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  883. Status: StatusActive, ProxyID: &parentProxy,
  884. Credentials: map[string]any{"chatgpt_account_id": "o"},
  885. }
  886. require.NoError(t, repo.Create(ctx, parent))
  887. shadow, err := svc.CreateShadow(ctx, parent.ID, ShadowOptions{Name: "s"})
  888. require.NoError(t, err)
  889. newProxy := int64(42)
  890. _, err = svc.BulkUpdateAccounts(ctx, &BulkUpdateAccountsInput{
  891. AccountIDs: []int64{shadow.ID},
  892. ProxyID: &newProxy,
  893. })
  894. require.Error(t, err, "批量给影子改 proxy 必须被拒")
  895. require.Equal(t, http.StatusBadRequest, infraerrors.Code(err), "应 400")
  896. require.NotNil(t, repo.accounts[shadow.ID].ProxyID)
  897. require.Equal(t, parentProxy, *repo.accounts[shadow.ID].ProxyID, "影子 proxy 必须保持继承母账号")
  898. }
  899. // TestForceOpenAIPrivacy_SkipsShadow 验证外审第4轮:影子隐私设置跳过(由母账号管理),
  900. // 早返不触碰任何依赖(svc 无 deps,若未守卫会 nil panic)。
  901. func TestForceOpenAIPrivacy_SkipsShadow(t *testing.T) {
  902. svc := &adminServiceImpl{}
  903. pid := int64(1)
  904. shadow := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &pid}
  905. require.Equal(t, "", svc.ForceOpenAIPrivacy(context.Background(), shadow), "影子隐私设置应跳过")
  906. }