@allbluecn/web-app 0.23.1 → 0.23.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +3 -3
  2. package/src/components/desktop/__tests__/desktop-state-listener.test.tsx +183 -0
  3. package/src/components/desktop/__tests__/sync-indicator.test.tsx +130 -0
  4. package/src/components/desktop/cloud-login.tsx +108 -6
  5. package/src/components/desktop/desktop-state-listener.tsx +76 -0
  6. package/src/components/desktop/shell-widgets.tsx +12 -5
  7. package/src/components/desktop/sync-indicator.tsx +62 -13
  8. package/src/components/layout/sidebar-03/__tests__/user-menu-anonymous.test.tsx +4 -0
  9. package/src/components/layout/sidebar-03/user-menu.tsx +63 -0
  10. package/src/lib/__tests__/session.test.ts +95 -0
  11. package/src/lib/auth/auth.config.ts +13 -0
  12. package/src/lib/changelog/sdk-versions.ts +1 -1
  13. package/src/lib/desktop-bridge-types.ts +36 -0
  14. package/src/lib/session.ts +15 -1
  15. package/src/paraglide/messages/_index.d.ts +11 -0
  16. package/src/paraglide/messages/_index.js +11 -0
  17. package/src/paraglide/messages/desktop_cloud_exchange_failed.d.ts +16 -0
  18. package/src/paraglide/messages/desktop_cloud_exchange_failed.js +29 -0
  19. package/src/paraglide/messages/desktop_state_cloud.d.ts +16 -0
  20. package/src/paraglide/messages/desktop_state_cloud.js +29 -0
  21. package/src/paraglide/messages/desktop_state_guest.d.ts +16 -0
  22. package/src/paraglide/messages/desktop_state_guest.js +29 -0
  23. package/src/paraglide/messages/desktop_state_transferring.d.ts +16 -0
  24. package/src/paraglide/messages/desktop_state_transferring.js +29 -0
  25. package/src/paraglide/messages/sync_bind_failed.d.ts +16 -0
  26. package/src/paraglide/messages/sync_bind_failed.js +29 -0
  27. package/src/paraglide/messages/sync_bind_in_progress.d.ts +16 -0
  28. package/src/paraglide/messages/sync_bind_in_progress.js +29 -0
  29. package/src/paraglide/messages/sync_bind_nonempty.d.ts +16 -0
  30. package/src/paraglide/messages/sync_bind_nonempty.js +29 -0
  31. package/src/paraglide/messages/sync_bind_probe_unreachable.d.ts +16 -0
  32. package/src/paraglide/messages/sync_bind_probe_unreachable.js +29 -0
  33. package/src/paraglide/messages/sync_bind_shell_unsupported.d.ts +16 -0
  34. package/src/paraglide/messages/sync_bind_shell_unsupported.js +29 -0
  35. package/src/paraglide/messages/sync_logout_failed.d.ts +16 -0
  36. package/src/paraglide/messages/sync_logout_failed.js +29 -0
  37. package/src/paraglide/messages/sync_logout_in_progress.d.ts +16 -0
  38. package/src/paraglide/messages/sync_logout_in_progress.js +29 -0
  39. package/src/routeTree.gen.ts +21 -0
  40. package/src/routes/api/sync/__tests__/account-status.test.ts +109 -0
  41. package/src/routes/api/sync/account-status.ts +39 -0
  42. package/src/server/__tests__/exchange-remote-token.test.ts +113 -0
  43. package/src/server/__tests__/remote-auth.test.ts +131 -1
  44. package/src/server/ai-gateway/__tests__/tools-authorization.test.ts +4 -2
  45. package/src/server/ai-gateway/tools/server.ts +7 -0
  46. package/src/server/desktop-bootstrap.ts +19 -3
  47. package/src/server/desktop-session-merge.ts +51 -0
  48. package/src/server/remote-auth.ts +61 -3
  49. package/src/server/serverFns/__tests__/knowledge-readable-kb.test.ts +21 -0
  50. package/src/server/serverFns/__tests__/workspace.test.ts +46 -0
  51. package/src/server/serverFns/auth/session.ts +28 -28
  52. package/src/server/serverFns/harness/attach-to-kb.ts +3 -1
  53. package/src/server/serverFns/knowledge.ts +4 -1
  54. package/src/server/serverFns/sync/transfer.ts +168 -0
  55. package/src/server/serverFns/workspace.ts +15 -0
  56. package/src/server/serverfn-public-allowlist.ts +4 -0
  57. package/src/server/sync/__tests__/ownership-transfer.test.ts +896 -0
  58. package/src/server/sync/account-status.ts +36 -0
  59. package/src/server/sync/ownership-transfer.ts +291 -0
  60. package/src/server/sync/saas-profile.ts +70 -0
@@ -0,0 +1,896 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 归属转移核心(正向,桌面本地优先 P2-A8a)回归哨兵。mock prisma 下钉死六条
6
+ // 安全不变式(真库断言归 A13 集成实测):
7
+ // ① 属主 UPDATE 覆盖固定清单的每一张表且 where 恒为 fromUserId;
8
+ // ② AiProviderConfig.userId(配置类)不在任何 UPDATE 中(TRANSFER_EXCLUDED);
9
+ // ③ 云身份行 upsert(载荷含传入昵称/头像)+ guest 行 deleteMany 被调;
10
+ // ④ syncOutbox/syncRowState 零触达(转移开着触发器自然产出 op='upsert' +
11
+ // base_version=0,推送拓扑 A7 已修为 upsert 正向——实现若手碰同步两表即红);
12
+ // ⑤ 幂等:第二次调用 UPDATE 计数 0、guest 行 deleteMany 计 0(delete 会抛
13
+ // P2025 破坏二跑——审查轮 1 修复后用例钉死 no-op 语义),返回 { tables, rows: 0 } 不抛错;
14
+ // ⑥ serverFn 校验:readRemoteAuth().userId ≠ data.cloudUserId 抛错(防参数伪造)。
15
+ // A8b 追加:⑦ 身份字段省略时 handler 以 remoteAuth.sessionToken 自取 SaaS
16
+ // profile(fetch stub),upsert 载荷携带自取资料;字段全传仍用传入值(向后兼容);
17
+ // ⑧ probeCloudAccountStatusFn 三态——空账号 { empty: true } / 非空 { empty: false }
18
+ // / 探测失败(非 200、网络异常、形状漂移)一律 { empty: false, unreachable: true }
19
+ // (宁可不转不可错转)。
20
+ // 另有清单对齐断言:实现导出的属主清单与测试内固定清单逐项相等——schema 增删
21
+ // User-relation FK 时此测试红(防实现清单与 schema 漂移的哨兵)。
22
+ // @vitest-environment node
23
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
24
+ import type { ZodType } from 'zod'
25
+
26
+ // ── 固定哨兵清单(schema.prisma @ a3adcc0a3 全量 User-relation FK 标量列 79 处,
27
+ // 减 TRANSFER_EXCLUDED 的 AiProviderConfig.userId = 78;扫描口径见
28
+ // ownership-transfer.ts 头注释)──────────────────────────────────────────
29
+ const TEST_OWNERSHIP_COLUMNS: readonly string[] = [
30
+ 'Account.userId',
31
+ 'AgentConversation.userId',
32
+ 'AiTask.createdById',
33
+ 'AiUsageLog.userId',
34
+ 'AnyHubApp.userId',
35
+ 'AnyHubMarketApp.userId',
36
+ 'AnyHubRecommendation.reviewedBy',
37
+ 'AnyHubRecommender.userId',
38
+ 'ArticleFavorite.userId',
39
+ 'Blob.userId',
40
+ 'BookmarkCategory.userId',
41
+ 'Bug.assigneeId',
42
+ 'Bug.createdById',
43
+ 'Bug.updatedById',
44
+ 'BugActivity.userId',
45
+ 'BugComment.userId',
46
+ 'BugCommentReaction.userId',
47
+ 'BugFilterView.userId',
48
+ 'BugReproRun.createdById',
49
+ 'BusinessAgentBinding.userId',
50
+ 'ChatThread.userId',
51
+ 'CodeSnippet.userId',
52
+ 'Comment.userId',
53
+ 'CommentIgnore.userId',
54
+ 'CommentReaction.userId',
55
+ 'Design.userId',
56
+ 'DesignVersion.userId',
57
+ 'DictionaryTerm.userId',
58
+ 'EmailPreference.userId',
59
+ 'EmailVerificationToken.userId',
60
+ 'HarnessPlugin.userId',
61
+ 'HarnessWorkspace.ownerId',
62
+ 'IndustryCompliance.userId',
63
+ 'InvitationUse.userId',
64
+ 'Iteration.createdById',
65
+ 'KnowledgeBase.userId',
66
+ 'KnowledgeDocument.createdBy',
67
+ 'KnowledgeShareRecord.userId',
68
+ 'Label.createdById',
69
+ 'LegalRegulation.userId',
70
+ 'LicenseKey.issuedById',
71
+ 'LoginLog.userId',
72
+ 'Notification.userId',
73
+ 'NotificationPreference.userId',
74
+ 'PasswordResetToken.userId',
75
+ 'Project.ownerId',
76
+ 'ProjectAccessToken.createdById',
77
+ 'ProjectActivity.userId',
78
+ 'ProjectMember.userId',
79
+ 'Review.reviewerId',
80
+ 'ReviewComment.userId',
81
+ 'RoleAssignment.userId',
82
+ 'SsoMapping.userId',
83
+ 'Story.assigneeId',
84
+ 'Story.createdById',
85
+ 'Story.updatedById',
86
+ 'StoryActivity.userId',
87
+ 'StoryComment.userId',
88
+ 'StoryFilterView.userId',
89
+ 'StoryReaction.userId',
90
+ 'Subscription.userId',
91
+ 'TeamInvitation.ownerId',
92
+ 'TeamJoinRequest.ownerId',
93
+ 'TeamJoinRequest.userId',
94
+ 'TeamMembership.memberId',
95
+ 'TeamMembership.ownerId',
96
+ 'TestAutomationArtifact.createdById',
97
+ 'TestCase.assigneeId',
98
+ 'TestCase.createdById',
99
+ 'TestCaseFilterView.userId',
100
+ 'TestGenerationRun.createdById',
101
+ 'TestRunCase.executedById',
102
+ 'UserAgent.userId',
103
+ 'UserSkill.userId',
104
+ 'WebsiteBookmark.userId',
105
+ 'Workspace.ownerId',
106
+ 'WorkspaceMember.userId',
107
+ 'WorkspaceQuickDoc.createdBy',
108
+ ]
109
+
110
+ // 逐字钉死 guest userId 契约(与 desktop-guest.GUEST_USER_ID 同值)
111
+ const GUEST = 'local-guest'
112
+ const CLOUD = 'cloud-user-1'
113
+ const CLOUD_NAME = '云上名字'
114
+ const CLOUD_EMAIL = 'cloud@example.com'
115
+ const CLOUD_AVATAR = { hat: 3, body: 1, face: 5 }
116
+
117
+ type UpdateManyFn = ReturnType<typeof vi.fn>
118
+
119
+ /** Prisma 模型名 → client delegate 名(AiProviderConfig → aiProviderConfig) */
120
+ const delegateOf = (model: string) => model.charAt(0).toLowerCase() + model.slice(1)
121
+
122
+ /** 清单涉及的 distinct 模型(多列模型如 TeamMembership 只建一份 delegate) */
123
+ const TEST_MODELS = [...new Set(TEST_OWNERSHIP_COLUMNS.map((e) => e.split('.')[0]))]
124
+
125
+ const touchable = () => ({
126
+ updateMany: vi.fn(),
127
+ update: vi.fn(),
128
+ deleteMany: vi.fn(),
129
+ delete: vi.fn(),
130
+ create: vi.fn(),
131
+ })
132
+
133
+ type TouchableMock = ReturnType<typeof touchable>
134
+ type DelegateMap = Record<string, { updateMany: UpdateManyFn }>
135
+ type PrismaMock = DelegateMap & {
136
+ aiProviderConfig: { updateMany: UpdateManyFn }
137
+ syncOutbox: TouchableMock
138
+ syncRowState: TouchableMock
139
+ user: { upsert: UpdateManyFn; deleteMany: UpdateManyFn }
140
+ $transaction: (fn: (tx: unknown) => Promise<unknown>) => Promise<unknown>
141
+ /** A9:$executeRaw 只存在于事务闭包的 tx 上;外层 prisma 恒 undefined
142
+ * (实现若绕过事务直连外层 raw 清表,此处 undefined 直接 TypeError 红) */
143
+ $executeRaw?: UpdateManyFn
144
+ /** $transaction 实际传入闭包的 tx 对象(执行序 / raw 断言入口) */
145
+ __tx?: { $executeRaw: UpdateManyFn }
146
+ }
147
+
148
+ function makePrismaMock(): PrismaMock {
149
+ const delegates: DelegateMap = {}
150
+ for (const model of TEST_MODELS) {
151
+ delegates[delegateOf(model)] = { updateMany: vi.fn() }
152
+ }
153
+ // 事务内客户端(tx):与外层共享全部 delegate / user / 同步两表引用(既有断言
154
+ // 经外层访问照常可见),独占 $executeRaw——外层 prisma 无此方法,实现把 raw 清
155
+ // 表写在事务闭包外(污染窗口)即 TypeError 红(同闭包哨兵,A9)
156
+ const tx = {
157
+ ...delegates,
158
+ // 排除类与同步两表:实现绝不以 delegate 触达,桩在此「能接住误触」
159
+ aiProviderConfig: { updateMany: vi.fn() },
160
+ syncOutbox: touchable(),
161
+ syncRowState: touchable(),
162
+ // deleteMany 首跑模拟真删(count=1);delete(单行)对已删行抛 P2025,
163
+ // 实现必须走 deleteMany——此处无 delete 桩,误用即 TypeError 红
164
+ user: { upsert: vi.fn(async () => ({})), deleteMany: vi.fn(async () => ({ count: 1 })) },
165
+ $executeRaw: vi.fn(async () => 1),
166
+ }
167
+ const prisma = { ...tx } as Record<string, unknown>
168
+ delete prisma.$executeRaw
169
+ prisma.$transaction = vi.fn(async (fn: (t: unknown) => Promise<unknown>) => fn(tx))
170
+ prisma.__tx = tx
171
+ // 对象展开会丢 index signature,显式回升(mock 形状与上方逐字一致)
172
+ return prisma as unknown as PrismaMock
173
+ }
174
+
175
+ /** 全部属主 delegate 的 updateMany 返回值统一设定(幂等用例置 0 模拟二跑空转;
176
+ * 缺列防呆用例删了 delegate,缺席跳过不炸桩) */
177
+ const setUpdateCounts = (prisma: PrismaMock, count: number) => {
178
+ for (const model of TEST_MODELS) {
179
+ ;(prisma[delegateOf(model)] as { updateMany: UpdateManyFn } | undefined)?.updateMany.mockResolvedValue({
180
+ count,
181
+ })
182
+ }
183
+ }
184
+
185
+ import {
186
+ OWNERSHIP_COLUMNS,
187
+ TRANSFER_EXCLUDED,
188
+ reverseTransferOwnership,
189
+ transferOwnership,
190
+ } from '../ownership-transfer'
191
+
192
+ describe('transferOwnership(核心)', () => {
193
+ let prisma: PrismaMock
194
+
195
+ beforeEach(() => {
196
+ prisma = makePrismaMock()
197
+ setUpdateCounts(prisma, 1)
198
+ })
199
+
200
+ it('清单对齐:实现导出属主清单与固定哨兵清单逐项相等(schema 漂移哨兵)', () => {
201
+ const impl = OWNERSHIP_COLUMNS.map((c) => `${c.table}.${c.column}`)
202
+ expect(impl).toHaveLength(TEST_OWNERSHIP_COLUMNS.length)
203
+ expect(new Set(impl).size).toBe(impl.length) // 无重复条目
204
+ expect([...impl].sort()).toEqual([...TEST_OWNERSHIP_COLUMNS].sort())
205
+ })
206
+
207
+ it('①全部属主列各被 UPDATE 一次且 where 恒为 fromUserId(覆盖逐项断言)', async () => {
208
+ const result = await transferOwnership(prisma as never, GUEST, {
209
+ id: CLOUD,
210
+ name: CLOUD_NAME,
211
+ email: CLOUD_EMAIL,
212
+ avatarConfig: CLOUD_AVATAR,
213
+ })
214
+
215
+ expect(result).toEqual({ tables: TEST_OWNERSHIP_COLUMNS.length, rows: TEST_OWNERSHIP_COLUMNS.length })
216
+
217
+ // 按模型聚合断言:每列恰一次、where/data 载荷逐 call 精确匹配
218
+ const columnsByModel = new Map<string, string[]>()
219
+ for (const entry of TEST_OWNERSHIP_COLUMNS) {
220
+ const [model, column] = entry.split('.')
221
+ columnsByModel.set(model, [...(columnsByModel.get(model) ?? []), column])
222
+ }
223
+ for (const [model, columns] of columnsByModel) {
224
+ const updateMany = (prisma[delegateOf(model)] as { updateMany: UpdateManyFn }).updateMany
225
+ const calls = updateMany.mock.calls as Array<
226
+ [{ where: Record<string, string>; data: Record<string, string> }]
227
+ >
228
+ expect(calls).toHaveLength(columns.length)
229
+ const touched = calls.map((c) => Object.keys(c[0].where)[0])
230
+ expect([...touched].sort()).toEqual([...columns].sort())
231
+ for (const call of calls) {
232
+ const col = Object.keys(call[0].where)[0]
233
+ expect(call[0]).toEqual({
234
+ where: { [col]: GUEST },
235
+ data: { [col]: CLOUD },
236
+ })
237
+ }
238
+ }
239
+ })
240
+
241
+ it('②AiProviderConfig.userId 不在任何 UPDATE 中(配置类排除)', async () => {
242
+ await transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME })
243
+
244
+ expect(prisma.aiProviderConfig.updateMany).not.toHaveBeenCalled()
245
+ // 排除常量钉死 + 实现清单零残留(双保险:改常量名/漏排除均在此报)
246
+ expect(TRANSFER_EXCLUDED).toContain('AiProviderConfig.userId')
247
+ expect(
248
+ OWNERSHIP_COLUMNS.some((c) => c.table === 'AiProviderConfig'),
249
+ ).toBe(false)
250
+ })
251
+
252
+ it('③云身份行 upsert(载荷含传入昵称/头像)+ guest 行 delete 被调', async () => {
253
+ await transferOwnership(prisma as never, GUEST, {
254
+ id: CLOUD,
255
+ name: CLOUD_NAME,
256
+ email: CLOUD_EMAIL,
257
+ avatarConfig: CLOUD_AVATAR,
258
+ })
259
+
260
+ expect(prisma.user.upsert).toHaveBeenCalledTimes(1)
261
+ const arg = (prisma.user.upsert as UpdateManyFn).mock.calls[0][0] as {
262
+ where: { id: string }
263
+ create: Record<string, unknown>
264
+ update: Record<string, unknown>
265
+ }
266
+ expect(arg.where).toEqual({ id: CLOUD })
267
+ expect(arg.create).toMatchObject({
268
+ id: CLOUD,
269
+ email: CLOUD_EMAIL,
270
+ username: CLOUD_NAME,
271
+ avatarConfig: CLOUD_AVATAR,
272
+ status: 'ACTIVE',
273
+ })
274
+ // password 随机占位(同 desktop-bootstrap 惯例:凭据登录必失败)
275
+ expect(typeof arg.create.password).toBe('string')
276
+ expect((arg.create.password as string).length).toBeGreaterThan(0)
277
+ // update 分支留空(幂等:云身份行已存在时不覆盖既有资料)
278
+ expect(arg.update).toEqual({})
279
+
280
+ // 删 guest 行走 deleteMany(delete 对已删行抛 P2025 破坏幂等二跑)
281
+ expect(prisma.user.deleteMany).toHaveBeenCalledTimes(1)
282
+ expect(prisma.user.deleteMany).toHaveBeenCalledWith({ where: { id: GUEST } })
283
+ })
284
+
285
+ it('④syncOutbox/syncRowState 零触达(outbox 语义保全:触发器自然产出 upsert)', async () => {
286
+ await transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME })
287
+
288
+ for (const table of ['syncOutbox', 'syncRowState'] as const) {
289
+ for (const fn of Object.keys(prisma[table])) {
290
+ expect(prisma[table][fn as keyof (typeof prisma)[typeof table]]).not.toHaveBeenCalled()
291
+ }
292
+ }
293
+ expect(
294
+ OWNERSHIP_COLUMNS.filter((c) => ['SyncOutbox', 'SyncRowState'].includes(c.table)),
295
+ ).toEqual([])
296
+ })
297
+
298
+ it('⑤幂等:重复调用 UPDATE 计数 0,返回 { tables, rows: 0 } 不抛错', async () => {
299
+ await transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME })
300
+
301
+ // 二跑:所有属主列 where fromUserId 匹配 0 行(已翻转);云身份行已存在 →
302
+ // upsert no-op;guest 行已删 → deleteMany 计 0 no-op(delete 在真库此处抛
303
+ // P2025 回滚二跑——审查轮 1 缺口,deleteMany 语义方为幂等)
304
+ setUpdateCounts(prisma, 0)
305
+ prisma.user.deleteMany.mockResolvedValue({ count: 0 })
306
+ await expect(
307
+ transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME }),
308
+ ).resolves.toEqual({ tables: TEST_OWNERSHIP_COLUMNS.length, rows: 0 })
309
+ })
310
+
311
+ it('缺列防呆:清单含 mock 未建的 delegate 时报错并回滚($transaction 不吞错)', async () => {
312
+ const broken = makePrismaMock()
313
+ // 事务闭包内的 tx 与外层共享 delegate 引用:删 tx 的(外层展开副本同步失引,
314
+ // 实现经 tx 取 delegate)——模拟「客户端缺该模型」
315
+ delete (broken as Record<string, unknown>).testCase
316
+ delete (broken.__tx as unknown as Record<string, unknown>).testCase
317
+ // 先给在册 delegate 填返回值:失败必须落在守卫(缺 TestCase)而非 undefined.count
318
+ setUpdateCounts(broken, 1)
319
+ await expect(
320
+ transferOwnership(broken as never, GUEST, { id: CLOUD, name: CLOUD_NAME }),
321
+ ).rejects.toThrow(/缺 TestCase delegate/)
322
+ })
323
+ })
324
+
325
+ // ── A9 反向转移核心(登出:cloud → guest,镜像正向六不变式)──────────────────
326
+
327
+ describe('reverseTransferOwnership(A9 反向核心)', () => {
328
+ let prisma: PrismaMock
329
+
330
+ beforeEach(() => {
331
+ prisma = makePrismaMock()
332
+ setUpdateCounts(prisma, 1)
333
+ })
334
+
335
+ it('①反向全量翻转:每列恰一次 UPDATE,where 恒为 cloudUserId / data 恒为 guest', async () => {
336
+ const result = await reverseTransferOwnership(prisma as never, CLOUD, GUEST)
337
+
338
+ expect(result).toEqual({ tables: TEST_OWNERSHIP_COLUMNS.length, rows: TEST_OWNERSHIP_COLUMNS.length })
339
+
340
+ const columnsByModel = new Map<string, string[]>()
341
+ for (const entry of TEST_OWNERSHIP_COLUMNS) {
342
+ const [model, column] = entry.split('.')
343
+ columnsByModel.set(model, [...(columnsByModel.get(model) ?? []), column])
344
+ }
345
+ for (const [model, columns] of columnsByModel) {
346
+ const updateMany = (prisma[delegateOf(model)] as { updateMany: UpdateManyFn }).updateMany
347
+ const calls = updateMany.mock.calls as Array<
348
+ [{ where: Record<string, string>; data: Record<string, string> }]
349
+ >
350
+ expect(calls).toHaveLength(columns.length)
351
+ const touched = calls.map((c) => Object.keys(c[0].where)[0])
352
+ expect([...touched].sort()).toEqual([...columns].sort())
353
+ for (const call of calls) {
354
+ const col = Object.keys(call[0].where)[0]
355
+ expect(call[0]).toEqual({
356
+ where: { [col]: CLOUD },
357
+ data: { [col]: GUEST },
358
+ })
359
+ }
360
+ }
361
+ })
362
+
363
+ it('②guest 行重建(影子行 upsert)先于属主翻转 + 云身份行 deleteMany 删除', async () => {
364
+ await reverseTransferOwnership(prisma as never, CLOUD, GUEST)
365
+
366
+ // guest 影子行 upsert(ensureLocalShadowUserWith 同款载荷;幂等 update 留空)
367
+ expect(prisma.user.upsert).toHaveBeenCalledTimes(1)
368
+ const arg = (prisma.user.upsert as UpdateManyFn).mock.calls[0][0] as {
369
+ where: { id: string }
370
+ create: Record<string, unknown>
371
+ update: Record<string, unknown>
372
+ }
373
+ expect(arg.where).toEqual({ id: GUEST })
374
+ expect(arg.create).toMatchObject({
375
+ id: GUEST,
376
+ username: '本地用户',
377
+ email: 'guest@localhost',
378
+ bgShape: 'square',
379
+ status: 'ACTIVE',
380
+ })
381
+ expect(typeof arg.create.password).toBe('string')
382
+ expect((arg.create.password as string).length).toBeGreaterThan(0)
383
+ expect(arg.update).toEqual({})
384
+
385
+ // 云身份行删除走 deleteMany(delete 对已删行抛 P2025 破坏幂等二跑)
386
+ expect(prisma.user.deleteMany).toHaveBeenCalledTimes(1)
387
+ expect(prisma.user.deleteMany).toHaveBeenCalledWith({ where: { id: CLOUD } })
388
+
389
+ // FK 目标先存在:guest 行 upsert 必须早于第一条属主 UPDATE(翻转的合法目标)
390
+ const upsertOrder = (prisma.user.upsert as UpdateManyFn).mock.invocationCallOrder[0]
391
+ const firstUpdateOrder = Math.min(
392
+ ...(prisma.story.updateMany as UpdateManyFn).mock.invocationCallOrder,
393
+ )
394
+ expect(upsertOrder).toBeLessThan(firstUpdateOrder)
395
+ })
396
+
397
+ it('③outbox/row_state 同事务清空:$executeRaw 含两条 DELETE,且在闭包内于翻转后执行', async () => {
398
+ await reverseTransferOwnership(prisma as never, CLOUD, GUEST)
399
+
400
+ // raw 清表只经事务闭包的 tx(外层 prisma 无 $executeRaw——绕过事务即 TypeError 红)
401
+ const raw = prisma.__tx!.$executeRaw
402
+ expect(raw).toHaveBeenCalledTimes(2)
403
+ const sqls = (raw.mock.calls as Array<[TemplateStringsArray]>).map((c) =>
404
+ String(c[0]),
405
+ )
406
+ expect(sqls.some((s) => s.includes('DELETE FROM sync_outbox'))).toBe(true)
407
+ expect(sqls.some((s) => s.includes('DELETE FROM sync_row_state'))).toBe(true)
408
+ expect(prisma.$executeRaw).toBeUndefined()
409
+
410
+ // 同一事务:$transaction 恰一次(无第二事务 / 无事务外窗口)
411
+ expect(prisma.$transaction).toHaveBeenCalledTimes(1)
412
+
413
+ // 闭包内执行序:guest 行重建 → 属主翻转 → 云行删除 → raw 清表(最后清同步两表,
414
+ // 清空后引擎无从产出反向 op——审查重点「防反向 op 推上云污染」)
415
+ const rawOrder = raw.mock.invocationCallOrder
416
+ const lastUpdateOrder = Math.max(
417
+ ...(prisma.story.updateMany as UpdateManyFn).mock.invocationCallOrder,
418
+ )
419
+ const deleteOrder = (prisma.user.deleteMany as UpdateManyFn).mock.invocationCallOrder[0]
420
+ expect(Math.min(...rawOrder)).toBeGreaterThan(lastUpdateOrder)
421
+ expect(Math.min(...rawOrder)).toBeGreaterThan(deleteOrder)
422
+ })
423
+
424
+ it('④AiProviderConfig.userId 不翻转(配置类排除同正向)', async () => {
425
+ await reverseTransferOwnership(prisma as never, CLOUD, GUEST)
426
+ expect(prisma.aiProviderConfig.updateMany).not.toHaveBeenCalled()
427
+ })
428
+
429
+ it('⑤幂等二跑:UPDATE 计 0、deleteMany 计 0、raw 清表照常,返回 rows=0 不抛错', async () => {
430
+ await reverseTransferOwnership(prisma as never, CLOUD, GUEST)
431
+
432
+ // 二跑:属主列 where cloudUserId 匹配 0 行(已回 guest);云身份行已删(deleteMany
433
+ // 计 0 no-op);guest 行 upsert 幂等(update 留空);raw 清表照常执行
434
+ setUpdateCounts(prisma, 0)
435
+ prisma.user.deleteMany.mockResolvedValue({ count: 0 })
436
+ await expect(
437
+ reverseTransferOwnership(prisma as never, CLOUD, GUEST),
438
+ ).resolves.toEqual({ tables: TEST_OWNERSHIP_COLUMNS.length, rows: 0 })
439
+ expect(prisma.__tx!.$executeRaw).toHaveBeenCalledTimes(4) // 2(首跑)+ 2(二跑)
440
+ })
441
+
442
+ it('⑥审查重点(mock 翻译):反向转移后 guest 对核心表读写可用——核心五表清单对齐 + 幂等二跑', async () => {
443
+ // 审查重点 3 用例(真库:guest 会话遍历 stories/bugs/testCases/knowledge 各查
444
+ // 一条写一条)的 mock 翻译:属主翻转覆盖核心表全部属主列(guest 行已重建 =
445
+ // FK 目标在位)+ deleteMany 幂等二跑 count=0(真库二跑不抛 P2025)。
446
+ const coreTables = [
447
+ ['story', 'Story'],
448
+ ['bug', 'Bug'],
449
+ ['testCase', 'TestCase'],
450
+ ['knowledgeBase', 'KnowledgeBase'],
451
+ ['knowledgeDocument', 'KnowledgeDocument'],
452
+ ] as const
453
+ await reverseTransferOwnership(prisma as never, CLOUD, GUEST)
454
+ for (const [delegate, model] of coreTables) {
455
+ const calls = (prisma[delegate] as { updateMany: UpdateManyFn }).updateMany.mock
456
+ .calls as Array<[{ where: Record<string, string>; data: Record<string, string> }]>
457
+ expect(calls.length).toBeGreaterThan(0)
458
+ for (const call of calls) {
459
+ expect(Object.values(call[0].where)).toEqual([CLOUD])
460
+ expect(Object.values(call[0].data)).toEqual([GUEST])
461
+ }
462
+ expect(TEST_OWNERSHIP_COLUMNS.some((c) => c.startsWith(`${model}.`))).toBe(true)
463
+ }
464
+
465
+ // 幂等二跑(真库语义:0 行匹配 / 行已删均 no-op,不破坏重试)
466
+ setUpdateCounts(prisma, 0)
467
+ prisma.user.deleteMany.mockResolvedValue({ count: 0 })
468
+ await expect(
469
+ reverseTransferOwnership(prisma as never, CLOUD, GUEST),
470
+ ).resolves.toBeDefined()
471
+ })
472
+
473
+ it('⑦源与目标相同(cloudUserId=guest)→ 拒绝执行', async () => {
474
+ await expect(
475
+ reverseTransferOwnership(prisma as never, GUEST, GUEST),
476
+ ).rejects.toThrow(/源与目标相同/)
477
+ expect(prisma.$transaction).not.toHaveBeenCalled()
478
+ })
479
+ })
480
+
481
+ // ── serverFn 校验(⑥):transferOwnershipFn — renderer 发起,鉴权链 mock ────
482
+
483
+ // A9:reverseTransferOwnership → ensureLocalShadowUserWith(desktop-bootstrap)
484
+ // 的 import 链会触达 @allbluecn/database 模块级 prisma 单例(顶层 await 建客户端,
485
+ // 缺 DATABASE_URL 即抛)——getter mock 拦住模块执行,单例在测试路径中不被消费
486
+ // (With 版本收 tx 入参,无参包装不在此文件调用),照 desktop-bootstrap.test.ts
487
+ // 「勿真连库」惯例。
488
+ const dbMock = vi.hoisted(() => ({ prisma: null as unknown }))
489
+ vi.mock('@allbluecn/database', () => ({
490
+ get prisma() {
491
+ return dbMock.prisma
492
+ },
493
+ }))
494
+
495
+ const sfMocks = vi.hoisted(() => ({
496
+ session: null as null | { user: { id: string } },
497
+ remoteAuth: null as null | { userId: string; sessionToken?: string },
498
+ accountEmpty: true,
499
+ prisma: null as unknown,
500
+ checkAuthRateLimit: vi.fn(),
501
+ clearRemoteAuth: vi.fn(),
502
+ }))
503
+
504
+ vi.mock('@tanstack/react-start', () => ({
505
+ // 与 design serverFn 测试同款:依序执行 middleware server 段(复现 context 合并
506
+ // 语义),真实鉴权链由被 mock 的 auth-helpers/getRequest 承接
507
+ createServerFn: () => {
508
+ const api: {
509
+ _middlewares: Array<{ options?: { server?: unknown } }>
510
+ _validator?: ZodType
511
+ middleware: (mws: Array<{ options?: { server?: unknown } }>) => unknown
512
+ validator: (schema: ZodType) => unknown
513
+ handler: (fn: (args: { data: unknown; context?: unknown }) => unknown) => unknown
514
+ } = {
515
+ _middlewares: [],
516
+ middleware(mws) {
517
+ this._middlewares.push(...mws)
518
+ return api
519
+ },
520
+ validator(schema) {
521
+ this._validator = schema
522
+ return api
523
+ },
524
+ handler(fn) {
525
+ return async (args: { data: unknown }) => {
526
+ const schema = this._validator as ZodType | undefined
527
+ const data = schema && args?.data !== undefined ? schema.parse(args.data) : args?.data
528
+ let context: unknown = {}
529
+ for (const mw of this._middlewares) {
530
+ const serverFn = mw?.options?.server as
531
+ | ((inner: {
532
+ data: unknown
533
+ context: unknown
534
+ method: string
535
+ serverFnMeta: Record<string, never>
536
+ signal: AbortSignal
537
+ next: (n: { context: unknown }) => Promise<{ context: unknown }>
538
+ }) => Promise<{ context: unknown }>)
539
+ | undefined
540
+ if (!serverFn) continue
541
+ const result = await serverFn({
542
+ data,
543
+ context,
544
+ method: 'POST',
545
+ serverFnMeta: {},
546
+ signal: new AbortController().signal,
547
+ next: async (n) => ({ context: { ...(context as object), ...((n?.context ?? {}) as object) } }),
548
+ })
549
+ context = result.context
550
+ }
551
+ return fn({ data, context })
552
+ }
553
+ },
554
+ }
555
+ return api
556
+ },
557
+ createMiddleware: (opts: { type?: string }) => {
558
+ const mw: { options: { type?: string; server?: unknown }; server?: unknown } = {
559
+ options: { type: opts?.type },
560
+ }
561
+ mw.server = (fn: unknown) => {
562
+ mw.options.server = fn
563
+ return mw
564
+ }
565
+ return mw
566
+ },
567
+ }))
568
+ vi.mock('@tanstack/react-start/server', () => ({
569
+ getRequest: () => new Request('https://allblue.test/rpc'),
570
+ }))
571
+ vi.mock('@/server/auth-helpers', () => ({
572
+ requireAuth: async () => {
573
+ if (!sfMocks.session?.user?.id) throw new Error('UNAUTHORIZED')
574
+ return sfMocks.session
575
+ },
576
+ }))
577
+ vi.mock('@/server/remote-auth', () => ({
578
+ readRemoteAuth: async () => sfMocks.remoteAuth,
579
+ clearRemoteAuth: sfMocks.clearRemoteAuth,
580
+ }))
581
+ vi.mock('@/server/sync/account-status', () => ({
582
+ isAccountEmpty: async () => sfMocks.accountEmpty,
583
+ }))
584
+ vi.mock('@/server/middlewares/rate-limit', () => ({
585
+ checkAuthRateLimit: sfMocks.checkAuthRateLimit,
586
+ }))
587
+ vi.mock('@allbluecn/database/get-prisma', () => ({
588
+ getPrisma: async () => sfMocks.prisma,
589
+ }))
590
+
591
+ import {
592
+ transferOwnershipFn,
593
+ probeCloudAccountStatusFn,
594
+ reverseTransferOwnershipFn,
595
+ } from '../../serverFns/sync/transfer'
596
+
597
+ type Fn = (args: { data: unknown }) => Promise<unknown>
598
+ const transferFn = transferOwnershipFn as unknown as Fn
599
+ const reverseFn = reverseTransferOwnershipFn as unknown as Fn
600
+ const probeFn = probeCloudAccountStatusFn as unknown as () => Promise<{
601
+ empty: boolean
602
+ unreachable?: boolean
603
+ }>
604
+
605
+ // SaaS fetch stub(saas-profile.ts / probeCloudAccountStatusFn 消费;vi.stubGlobal
606
+ // 拦截,不真出网——惯例照 exchange-remote-token.test.ts)
607
+ const fetchMock = vi.hoisted(() => vi.fn())
608
+
609
+ describe('transferOwnershipFn(serverFn 校验)', () => {
610
+ beforeEach(() => {
611
+ sfMocks.session = { user: { id: GUEST } }
612
+ sfMocks.remoteAuth = { userId: CLOUD, sessionToken: 'tok-123' }
613
+ sfMocks.accountEmpty = true
614
+ sfMocks.prisma = makePrismaMock()
615
+ setUpdateCounts(sfMocks.prisma as PrismaMock, 1)
616
+ fetchMock.mockReset()
617
+ })
618
+
619
+ afterEach(() => {
620
+ vi.unstubAllGlobals()
621
+ })
622
+
623
+ it('⑥remoteAuth.userId ≠ data.cloudUserId → 抛错,零触库', async () => {
624
+ sfMocks.remoteAuth = { userId: 'attacker-other' }
625
+
626
+ await expect(
627
+ transferFn({ data: { cloudUserId: CLOUD, name: CLOUD_NAME } }),
628
+ ).rejects.toThrow(/云账号|remoteAuth|不匹配/)
629
+
630
+ const p = sfMocks.prisma as PrismaMock
631
+ expect(p.story.updateMany).not.toHaveBeenCalled()
632
+ expect(p.user.upsert).not.toHaveBeenCalled()
633
+ })
634
+
635
+ it('无 remoteAuth(非 desktop / 未交换登录)→ 抛错', async () => {
636
+ sfMocks.remoteAuth = null
637
+
638
+ await expect(
639
+ transferFn({ data: { cloudUserId: CLOUD, name: CLOUD_NAME } }),
640
+ ).rejects.toThrow()
641
+ expect((sfMocks.prisma as PrismaMock).story.updateMany).not.toHaveBeenCalled()
642
+ })
643
+
644
+ it('账号非空(account-status.empty=false)→ 抛错拒绝转移', async () => {
645
+ sfMocks.accountEmpty = false
646
+
647
+ await expect(
648
+ transferFn({ data: { cloudUserId: CLOUD, name: CLOUD_NAME } }),
649
+ ).rejects.toThrow(/空账号|非空/)
650
+ expect((sfMocks.prisma as PrismaMock).story.updateMany).not.toHaveBeenCalled()
651
+ })
652
+
653
+ it('正向:guest 会话 + remoteAuth 匹配 + 空账号 → 以 GUEST→cloudUserId 转移并回传结果', async () => {
654
+ const result = (await transferFn({
655
+ data: {
656
+ cloudUserId: CLOUD,
657
+ name: CLOUD_NAME,
658
+ email: CLOUD_EMAIL,
659
+ avatarConfig: CLOUD_AVATAR,
660
+ },
661
+ })) as { tables: number; rows: number }
662
+
663
+ expect(result).toEqual({ tables: TEST_OWNERSHIP_COLUMNS.length, rows: TEST_OWNERSHIP_COLUMNS.length })
664
+ const p = sfMocks.prisma as PrismaMock
665
+ expect(p.story.updateMany).toHaveBeenCalledWith({
666
+ where: { createdById: GUEST },
667
+ data: { createdById: CLOUD },
668
+ })
669
+ expect(p.user.upsert).toHaveBeenCalledTimes(1)
670
+ expect(p.user.deleteMany).toHaveBeenCalledWith({ where: { id: GUEST } })
671
+ })
672
+
673
+ it('validator:cloudUserId 空串/缺失 → 校验失败', async () => {
674
+ await expect(transferFn({ data: { cloudUserId: '', name: 'x' } })).rejects.toThrow()
675
+ await expect(transferFn({ data: { name: 'x' } })).rejects.toThrow()
676
+ })
677
+ })
678
+
679
+ // ── A9:reverseTransferOwnershipFn(登出反向转移 serverFn 校验)──────────────
680
+
681
+ describe('reverseTransferOwnershipFn(A9 serverFn 校验)', () => {
682
+ beforeEach(() => {
683
+ sfMocks.session = { user: { id: CLOUD } }
684
+ sfMocks.remoteAuth = { userId: CLOUD, sessionToken: 'tok-123' }
685
+ sfMocks.accountEmpty = true
686
+ sfMocks.prisma = makePrismaMock()
687
+ setUpdateCounts(sfMocks.prisma as PrismaMock, 1)
688
+ sfMocks.clearRemoteAuth.mockClear()
689
+ // 桌面守卫开闸(isDesktopServer 消费 DEPLOY_TARGET,照 probeFn 用例先例)
690
+ vi.stubEnv('DEPLOY_TARGET', 'desktop')
691
+ })
692
+
693
+ afterEach(() => {
694
+ vi.unstubAllEnvs()
695
+ })
696
+
697
+ it('①remoteAuth 缺失(非云态/已清理)→ 拒绝,零触库零清理', async () => {
698
+ sfMocks.remoteAuth = null
699
+
700
+ await expect(reverseFn({ data: { cloudUserId: CLOUD } })).rejects.toThrow(/云账号|remoteAuth|不匹配/)
701
+
702
+ const p = sfMocks.prisma as PrismaMock
703
+ expect(p.story.updateMany).not.toHaveBeenCalled()
704
+ expect(p.user.upsert).not.toHaveBeenCalled()
705
+ expect(sfMocks.clearRemoteAuth).not.toHaveBeenCalled()
706
+ })
707
+
708
+ it('②remoteAuth.userId ≠ data.cloudUserId(参数伪造)→ 拒绝', async () => {
709
+ sfMocks.remoteAuth = { userId: 'attacker-other' }
710
+
711
+ await expect(reverseFn({ data: { cloudUserId: CLOUD } })).rejects.toThrow(/不匹配/)
712
+ expect((sfMocks.prisma as PrismaMock).story.updateMany).not.toHaveBeenCalled()
713
+ expect(sfMocks.clearRemoteAuth).not.toHaveBeenCalled()
714
+ })
715
+
716
+ it('③会话身份 ≠ cloudUserId(本地凭据会话与云身份并存防御)→ 拒绝', async () => {
717
+ sfMocks.session = { user: { id: 'local-auth-user' } }
718
+
719
+ await expect(reverseFn({ data: { cloudUserId: CLOUD } })).rejects.toThrow(/会话|身份/)
720
+ expect((sfMocks.prisma as PrismaMock).story.updateMany).not.toHaveBeenCalled()
721
+ expect(sfMocks.clearRemoteAuth).not.toHaveBeenCalled()
722
+ })
723
+
724
+ it('④非 desktop 部署 → 拒绝(桌面守卫)', async () => {
725
+ vi.stubEnv('DEPLOY_TARGET', 'vercel')
726
+
727
+ await expect(reverseFn({ data: { cloudUserId: CLOUD } })).rejects.toThrow(/仅桌面端/)
728
+ expect((sfMocks.prisma as PrismaMock).story.updateMany).not.toHaveBeenCalled()
729
+ })
730
+
731
+ it('⑤成功:cloud→guest 反向转移执行 + 事务提交后清 remote-auth(崩窗口收敛)', async () => {
732
+ const result = (await reverseFn({ data: { cloudUserId: CLOUD } })) as {
733
+ tables: number
734
+ rows: number
735
+ }
736
+
737
+ expect(result).toEqual({ tables: TEST_OWNERSHIP_COLUMNS.length, rows: TEST_OWNERSHIP_COLUMNS.length })
738
+ const p = sfMocks.prisma as PrismaMock
739
+ expect(p.story.updateMany).toHaveBeenCalledWith({
740
+ where: { createdById: CLOUD },
741
+ data: { createdById: GUEST },
742
+ })
743
+ expect(p.user.upsert).toHaveBeenCalledTimes(1)
744
+ expect(p.user.deleteMany).toHaveBeenCalledWith({ where: { id: CLOUD } })
745
+ // 反向转移一旦提交,数据已是 guest 属主——remote-auth 残留会造「云态零数据」
746
+ // 假丢数据窗口(A8b 发现 2 的镜像),serverFn 提交后立即清理(幂等)
747
+ expect(sfMocks.clearRemoteAuth).toHaveBeenCalledTimes(1)
748
+ })
749
+
750
+ it('validator:cloudUserId 空串/缺失 → 校验失败', async () => {
751
+ await expect(reverseFn({ data: { cloudUserId: '' } })).rejects.toThrow()
752
+ await expect(reverseFn({ data: {} })).rejects.toThrow()
753
+ })
754
+ })
755
+
756
+ // ── A8b ⑦:身份字段省略 → 自取 SaaS profile(向后兼容:全传仍用传入值)────
757
+
758
+ const SAAS_PROFILE = {
759
+ user: {
760
+ id: CLOUD,
761
+ name: '云端昵称',
762
+ email: 'saas@example.com',
763
+ avatarConfig: { hat: 2, body: 4 },
764
+ bgShape: 'circle',
765
+ bgColor: '#3b82f6',
766
+ },
767
+ }
768
+
769
+ describe('transferOwnershipFn 身份字段省略(A8b 自取 SaaS profile)', () => {
770
+ beforeEach(() => {
771
+ sfMocks.session = { user: { id: GUEST } }
772
+ sfMocks.remoteAuth = { userId: CLOUD, sessionToken: 'tok-123' }
773
+ sfMocks.accountEmpty = true
774
+ sfMocks.prisma = makePrismaMock()
775
+ setUpdateCounts(sfMocks.prisma as PrismaMock, 1)
776
+ fetchMock.mockReset()
777
+ vi.stubGlobal('fetch', fetchMock)
778
+ })
779
+
780
+ afterEach(() => {
781
+ vi.unstubAllGlobals()
782
+ })
783
+
784
+ it('name 省略 → 以 remoteAuth.sessionToken 自取 profile,upsert 载荷携带自取资料', async () => {
785
+ fetchMock.mockResolvedValue({ ok: true, json: async () => SAAS_PROFILE })
786
+
787
+ const result = (await transferFn({ data: { cloudUserId: CLOUD } })) as { rows: number }
788
+ expect(result.rows).toBe(TEST_OWNERSHIP_COLUMNS.length)
789
+
790
+ // 自取请求:SaaS exchange 端点 + 云会话 token cookie(照 exchange 先例)
791
+ expect(fetchMock).toHaveBeenCalledTimes(1)
792
+ const [url, init] = fetchMock.mock.calls[0] as [string, { headers: { Cookie: string } }]
793
+ expect(url).toContain('/api/auth/desktop-callback?exchange=true')
794
+ expect(init.headers.Cookie).toBe('auth.session-token=tok-123')
795
+
796
+ // 云身份行 upsert:昵称/邮箱/头像来自 SaaS(缺 name 兜底 = cloudUserId)
797
+ const arg = (sfMocks.prisma as PrismaMock).user.upsert.mock.calls[0][0] as {
798
+ create: Record<string, unknown>
799
+ }
800
+ expect(arg.create).toMatchObject({
801
+ id: CLOUD,
802
+ username: '云端昵称',
803
+ email: 'saas@example.com',
804
+ avatarConfig: { hat: 2, body: 4 },
805
+ bgShape: 'circle',
806
+ bgColor: '#3b82f6',
807
+ })
808
+ })
809
+
810
+ it('字段全传 → 用传入值,零 SaaS 出网(A8a 调用形态向后兼容)', async () => {
811
+ await transferFn({
812
+ data: { cloudUserId: CLOUD, name: CLOUD_NAME, email: CLOUD_EMAIL, avatarConfig: CLOUD_AVATAR },
813
+ })
814
+ expect(fetchMock).not.toHaveBeenCalled()
815
+ const arg = (sfMocks.prisma as PrismaMock).user.upsert.mock.calls[0][0] as {
816
+ create: Record<string, unknown>
817
+ }
818
+ expect(arg.create).toMatchObject({
819
+ username: CLOUD_NAME,
820
+ email: CLOUD_EMAIL,
821
+ avatarConfig: CLOUD_AVATAR,
822
+ })
823
+ })
824
+
825
+ it('name 省略且 SaaS 资料获取失败 → 抛错走 bind:fail,零触库', async () => {
826
+ fetchMock.mockResolvedValue({ ok: false, status: 503 })
827
+
828
+ await expect(transferFn({ data: { cloudUserId: CLOUD } })).rejects.toThrow(/SaaS/)
829
+ const p = sfMocks.prisma as PrismaMock
830
+ expect(p.story.updateMany).not.toHaveBeenCalled()
831
+ expect(p.user.upsert).not.toHaveBeenCalled()
832
+ })
833
+
834
+ it('name 省略且无 sessionToken → 抛错,零出网零触库', async () => {
835
+ sfMocks.remoteAuth = { userId: CLOUD }
836
+ await expect(transferFn({ data: { cloudUserId: CLOUD } })).rejects.toThrow(/token/)
837
+ expect(fetchMock).not.toHaveBeenCalled()
838
+ expect((sfMocks.prisma as PrismaMock).story.updateMany).not.toHaveBeenCalled()
839
+ })
840
+ })
841
+
842
+ // ── A8b ⑧:probeCloudAccountStatusFn 三态(claim 探测)────────────────────
843
+
844
+ describe('probeCloudAccountStatusFn(claim 探测三态)', () => {
845
+ beforeEach(() => {
846
+ sfMocks.session = { user: { id: GUEST } }
847
+ sfMocks.remoteAuth = { userId: CLOUD, sessionToken: 'tok-123' }
848
+ sfMocks.checkAuthRateLimit.mockReset()
849
+ fetchMock.mockReset()
850
+ // 桌面守卫(isDesktopServer)开闸:本 serverFn 与 exchangeRemoteTokenFn 同口径
851
+ vi.stubEnv('DEPLOY_TARGET', 'desktop')
852
+ vi.stubGlobal('fetch', fetchMock)
853
+ })
854
+
855
+ afterEach(() => {
856
+ vi.unstubAllEnvs()
857
+ vi.unstubAllGlobals()
858
+ })
859
+
860
+ it('SaaS 200 空账号 → { empty: true }(cookie 携带云会话 token)', async () => {
861
+ fetchMock.mockResolvedValue({ ok: true, json: async () => ({ empty: true }) })
862
+ await expect(probeFn()).resolves.toEqual({ empty: true })
863
+ const [url, init] = fetchMock.mock.calls[0] as [string, { headers: { Cookie: string } }]
864
+ expect(url).toContain('/api/sync/account-status')
865
+ expect(init.headers.Cookie).toBe('auth.session-token=tok-123')
866
+ })
867
+
868
+ it('SaaS 200 非空账号 → { empty: false }', async () => {
869
+ fetchMock.mockResolvedValue({ ok: true, json: async () => ({ empty: false }) })
870
+ await expect(probeFn()).resolves.toEqual({ empty: false })
871
+ })
872
+
873
+ it('探测失败三形态(非 200 / 网络异常 / 形状漂移)→ 一律 { empty: false, unreachable: true }', async () => {
874
+ fetchMock.mockResolvedValue({ ok: false, status: 503 })
875
+ await expect(probeFn()).resolves.toEqual({ empty: false, unreachable: true })
876
+
877
+ fetchMock.mockRejectedValue(new Error('ECONNREFUSED'))
878
+ await expect(probeFn()).resolves.toEqual({ empty: false, unreachable: true })
879
+
880
+ fetchMock.mockResolvedValue({ ok: true, json: async () => ({ empty: 'yes' }) })
881
+ await expect(probeFn()).resolves.toEqual({ empty: false, unreachable: true })
882
+ })
883
+
884
+ it('无云会话(未交换登录)→ 抛错,零出网', async () => {
885
+ sfMocks.remoteAuth = { userId: CLOUD }
886
+ await expect(probeFn()).rejects.toThrow(/云会话/)
887
+ expect(fetchMock).not.toHaveBeenCalled()
888
+ })
889
+
890
+ it('限流挂载:走 checkAuthRateLimit(防探测代理暴力面)', async () => {
891
+ fetchMock.mockResolvedValue({ ok: true, json: async () => ({ empty: true }) })
892
+ await probeFn()
893
+ expect(sfMocks.checkAuthRateLimit).toHaveBeenCalledTimes(1)
894
+ expect(sfMocks.checkAuthRateLimit.mock.calls[0][0]).toBe('probeCloudAccountStatus')
895
+ })
896
+ })