@allbluecn/web-app 0.23.3 → 0.23.5
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.
- package/package.json +3 -3
- package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +2 -0
- package/src/components/desktop/__tests__/cloud-login-rollback.test.tsx +161 -0
- package/src/components/desktop/cloud-login.tsx +29 -26
- package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +194 -0
- package/src/components/desktop-agent/__tests__/agent-tool-card.test.tsx +138 -0
- package/src/components/desktop-agent/__tests__/agent-workspace-card.test.tsx +189 -0
- package/src/components/desktop-agent/agent-chat.tsx +221 -0
- package/src/components/desktop-agent/agent-tool-card.tsx +185 -0
- package/src/components/desktop-agent/agent-workspace-card.tsx +176 -0
- package/src/components/layout/sidebar-03/app-sidebar.tsx +9 -2
- package/src/config/sidebar-routes.tsx +14 -1
- package/src/lib/changelog/sdk-versions.ts +1 -1
- package/src/lib/desktop-agent/__tests__/connection.test.ts +223 -0
- package/src/lib/desktop-agent/__tests__/errors.test.ts +43 -0
- package/src/lib/desktop-agent/__tests__/tool-result.test.ts +163 -0
- package/src/lib/desktop-agent/__tests__/use-desktop-agent-presence.test.tsx +181 -0
- package/src/lib/desktop-agent/availability.ts +39 -0
- package/src/lib/desktop-agent/connection.ts +72 -0
- package/src/lib/desktop-agent/errors.ts +21 -0
- package/src/lib/desktop-agent/tool-result.ts +170 -0
- package/src/lib/desktop-agent/use-desktop-agent-presence.ts +64 -0
- package/src/lib/desktop-bridge-types.ts +22 -1
- package/src/lib/hotkeys/__tests__/registry.test.ts +11 -4
- package/src/lib/hotkeys/registry.ts +9 -1
- package/src/lib/queries/README.md +1 -0
- package/src/lib/queries/__tests__/desktop-agent-queries.test.ts +80 -0
- package/src/lib/queries/desktop-agent.ts +25 -0
- package/src/paraglide/messages/_index.d.ts +42 -0
- package/src/paraglide/messages/_index.js +42 -0
- package/src/paraglide/messages/agent_desktop_chatcwd1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_chatcwd1.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatemptyhint2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatemptyhint2.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatemptytitle2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatemptytitle2.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatindependenthint2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatindependenthint2.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatnoworkspace2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatnoworkspace2.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatplaceholder1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatplaceholder1.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatsend1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatsend1.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatstop1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatstop1.js +30 -0
- package/src/paraglide/messages/agent_desktop_chatthinking1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_chatthinking1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorconnection1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_errorconnection1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorforbidden1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_errorforbidden1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorgeneric1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_errorgeneric1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorunauthorized1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_errorunauthorized1.js +30 -0
- package/src/paraglide/messages/agent_desktop_errorunavailable1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_errorunavailable1.js +30 -0
- package/src/paraglide/messages/agent_desktop_pagedescription1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_pagedescription1.js +30 -0
- package/src/paraglide/messages/agent_desktop_title.d.ts +16 -0
- package/src/paraglide/messages/agent_desktop_title.js +29 -0
- package/src/paraglide/messages/agent_desktop_toolcountfiles2.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolcountfiles2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolcounthits2.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolcounthits2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolcountlines2.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolcountlines2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolemptydir2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_toolemptydir2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolerror1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_toolerror1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolfind1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolfind1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolgrep1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolgrep1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolls1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolls1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolmore1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolmore1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolnomatches2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_toolnomatches2.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolother1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolother1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolread1.d.ts +19 -0
- package/src/paraglide/messages/agent_desktop_toolread1.js +30 -0
- package/src/paraglide/messages/agent_desktop_toolrunning1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_toolrunning1.js +30 -0
- package/src/paraglide/messages/agent_desktop_tooltruncated1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_tooltruncated1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceadd1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceadd1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceaddfailed2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceaddfailed2.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspacedescription1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspacedescription1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceempty1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceempty1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceemptyhint2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceemptyhint2.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceloadfailed2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceloadfailed2.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceremove1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceremove1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceremovefailed2.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceremovefailed2.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspacetitle1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspacetitle1.js +30 -0
- package/src/paraglide/messages/agent_desktop_workspaceunsupported1.d.ts +17 -0
- package/src/paraglide/messages/agent_desktop_workspaceunsupported1.js +30 -0
- package/src/paraglide/messages/common_hotkey_desc_agentabort1.d.ts +17 -0
- package/src/paraglide/messages/common_hotkey_desc_agentabort1.js +30 -0
- package/src/paraglide/messages/sync_bind_rolled_back.d.ts +16 -0
- package/src/paraglide/messages/sync_bind_rolled_back.js +29 -0
- package/src/routeTree.gen.ts +23 -0
- package/src/routes/-tests/agent-gate.test.ts +45 -0
- package/src/routes/_nav/agent/route.lazy.tsx +63 -0
- package/src/routes/_nav/agent/route.tsx +21 -0
- package/src/routes/api/sync/account-status.ts +4 -2
- package/src/routes/api/sync/push.ts +11 -3
- package/src/server/__tests__/desktop-cloud-identity.test.ts +331 -0
- package/src/server/__tests__/desktop-session-merge.test.ts +42 -0
- package/src/server/__tests__/exchange-remote-token.test.ts +92 -10
- package/src/server/__tests__/remote-auth.test.ts +39 -0
- package/src/server/desktop-bootstrap.ts +16 -5
- package/src/server/desktop-cloud-identity.ts +247 -0
- package/src/server/remote-auth.ts +33 -1
- package/src/server/serverFns/auth/session.ts +27 -2
- package/src/server/serverFns/sync/transfer.ts +28 -27
- package/src/server/serverFns/workspace-kb.ts +10 -0
- package/src/server/sync/__tests__/ownership-transfer.test.ts +261 -48
- package/src/server/sync/__tests__/sync-first-push.e2e.test.ts +349 -0
- package/src/server/sync/account-status-cloud.ts +41 -0
- package/src/server/sync/ownership-transfer.ts +88 -49
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
// cookie 天然过鉴权(backend 进程无 cookie,不得由 bind-flow 直调——复审⑥ P1-8)。
|
|
8
8
|
// 校验链:① readRemoteAuth().userId 必须等于 data.cloudUserId(转移目标必须等于
|
|
9
9
|
// 已交换登录的云账号,防参数伪造;非 desktop / 无 remoteAuth 时 readRemoteAuth
|
|
10
|
-
// 恒 null → 拒绝);②
|
|
11
|
-
//
|
|
12
|
-
// GUEST_USER_ID → cloudUserId 的单事务转移。
|
|
13
|
-
//
|
|
10
|
+
// 恒 null → 拒绝);② 复核目标为空账号(**云端权威**:SaaS /api/sync/account-status,
|
|
11
|
+
// I2 裁定——本地 PGlite 看不到云端数据,且转移后本地计数恒 >0 会破坏幂等重试);
|
|
12
|
+
// ③ 调 transferOwnership 执行 GUEST_USER_ID → cloudUserId 的单事务转移。
|
|
13
|
+
// outbox 语义由触发器自然产出(op='upsert' + base_version=0),本 serverFn
|
|
14
|
+
// 不触碰同步两表。
|
|
14
15
|
// 身份字段(A8b 裁定):name/avatarConfig 等可省略——云昵称/头像仅 SaaS 权威
|
|
15
16
|
// (remote-auth.json session 块不存 profile,renderer 拿不到),省略时 handler
|
|
16
17
|
// 以 remoteAuth.sessionToken 自取 SaaS profile 补齐;字段全传则用传入值(向后
|
|
@@ -27,9 +28,9 @@ import { authContextMiddleware } from "@/server/middlewares/auth-context"
|
|
|
27
28
|
import { checkAuthRateLimit } from "@/server/middlewares/rate-limit"
|
|
28
29
|
import { readRemoteAuth, clearRemoteAuth } from "@/server/remote-auth"
|
|
29
30
|
import { GUEST_USER_ID, isDesktopServer } from "@/server/desktop-guest"
|
|
30
|
-
import {
|
|
31
|
+
import { probeCloudAccountStatus } from "@/server/sync/account-status-cloud"
|
|
31
32
|
import { reverseTransferOwnership, transferOwnership } from "@/server/sync/ownership-transfer"
|
|
32
|
-
import { fetchSaasProfile
|
|
33
|
+
import { fetchSaasProfile } from "@/server/sync/saas-profile"
|
|
33
34
|
|
|
34
35
|
const transferInputSchema = z.object({
|
|
35
36
|
cloudUserId: z.string().min(1),
|
|
@@ -52,10 +53,17 @@ export const transferOwnershipFn = createServerFn({ method: "POST" })
|
|
|
52
53
|
)
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
// ②
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
// ② 复核目标为空账号(仅空账号才允许归属转移,一期唯一路径)。
|
|
57
|
+
// I2 裁定:改用**云端权威**探测(SaaS /api/sync/account-status)——原先
|
|
58
|
+
// 用本地 PGlite 按 cloudUserId 计数,转移前恒 0(真)、转移后恒 >0(假),
|
|
59
|
+
// 幂等重试被自己的本地数据误判成「目标账号非空」;本地库也看不到云端数据。
|
|
60
|
+
// 不可达一律拒绝(宁可不转不可错转)。
|
|
61
|
+
const account = await probeCloudAccountStatus(remoteAuth.sessionToken)
|
|
62
|
+
if (account.unreachable) {
|
|
63
|
+
throw new Error("[sync/transfer] 无法确认目标账号状态(云端不可达),拒绝归属转移")
|
|
64
|
+
}
|
|
65
|
+
if (!account.empty) {
|
|
66
|
+
throw new Error("[sync/transfer] 目标账号非空,一期仅支持空账号转移")
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
// ②b 身份字段补齐(A8b 裁定):name 省略 = renderer 未传身份字段(一期主
|
|
@@ -76,6 +84,7 @@ export const transferOwnershipFn = createServerFn({ method: "POST" })
|
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
// ③ 单事务转移 GUEST → cloudUserId(失败整体回滚,调用方走 bind:fail 恢复)
|
|
87
|
+
const prisma = await getPrisma()
|
|
79
88
|
return transferOwnership(prisma, GUEST_USER_ID, {
|
|
80
89
|
id: data.cloudUserId,
|
|
81
90
|
name,
|
|
@@ -92,7 +101,8 @@ export const transferOwnershipFn = createServerFn({ method: "POST" })
|
|
|
92
101
|
// (登出者必须是当前云身份本人;无 remoteAuth = 已登出/非云态 → 拒绝);③ 会话
|
|
93
102
|
// 身份 === data.cloudUserId(防「本地凭据会话与云身份并存」时误触发反向转移——
|
|
94
103
|
// authjs 会话有效时 session.user.id 是本地用户而非云 id);④ 单事务
|
|
95
|
-
// cloud → GUEST 反向转移(outbox
|
|
104
|
+
// cloud → GUEST 反向转移(outbox 同事务清空防反向 op 上云;row_state 保留以维持
|
|
105
|
+
// base_version 连续性——裁定 R2)。
|
|
96
106
|
// 事务提交成功后立即清 remote-auth(幂等):反向转移一旦提交数据已 guest 属主,
|
|
97
107
|
// 云身份残留会让 fallback 会话显示「云态零数据」假丢数据窗口(A8b 发现 2 的镜像
|
|
98
108
|
// ——正向是转移成功 + 云身份未写,反向是转移成功 + 云身份未清)。renderer 侧的
|
|
@@ -132,9 +142,10 @@ export const reverseTransferOwnershipFn = createServerFn({ method: "POST" })
|
|
|
132
142
|
})
|
|
133
143
|
|
|
134
144
|
// claim 探测(A8b 编排时序第 3 步):exchange 激活后、bind:begin 前,renderer
|
|
135
|
-
// 经本端点代持云会话 token
|
|
136
|
-
//
|
|
137
|
-
//
|
|
145
|
+
// 经本端点代持云会话 token 探测目标云账号是否空账号(账号状态只有 SaaS 权威,
|
|
146
|
+
// 本地库看不到云端数据;探测逻辑与 transferOwnershipFn 的复核共用
|
|
147
|
+
// @/server/sync/account-status-cloud,I2 抽共享)。探测失败(SaaS 不可达/非
|
|
148
|
+
// 200/形状漂移)一律按非空处理(宁可不转不可错转),unreachable 标记供
|
|
138
149
|
// renderer 区分提示;非空账号一期不支持转移。
|
|
139
150
|
export const probeCloudAccountStatusFn = createServerFn({ method: "POST" })
|
|
140
151
|
.middleware([authContextMiddleware])
|
|
@@ -152,17 +163,7 @@ export const probeCloudAccountStatusFn = createServerFn({ method: "POST" })
|
|
|
152
163
|
throw new Error("[sync/transfer] 无云会话(未交换登录),无法探测账号状态")
|
|
153
164
|
}
|
|
154
165
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
credentials: "omit",
|
|
159
|
-
signal: AbortSignal.timeout(10_000),
|
|
160
|
-
})
|
|
161
|
-
if (!res.ok) return { empty: false, unreachable: true }
|
|
162
|
-
const json = (await res.json().catch(() => null)) as { empty?: unknown } | null
|
|
163
|
-
if (typeof json?.empty !== "boolean") return { empty: false, unreachable: true }
|
|
164
|
-
return json.empty ? { empty: true } : { empty: false }
|
|
165
|
-
} catch {
|
|
166
|
-
return { empty: false, unreachable: true }
|
|
167
|
-
}
|
|
166
|
+
const status = await probeCloudAccountStatus(remoteAuth.sessionToken)
|
|
167
|
+
if (status.empty) return { empty: true }
|
|
168
|
+
return status.unreachable ? { empty: false, unreachable: true } : { empty: false }
|
|
168
169
|
})
|
|
@@ -191,6 +191,16 @@ export const listWorkspaceAvailableKbsFn = createServerFn({ method: "GET" })
|
|
|
191
191
|
return kbs.map((k) => ({ ...k, linked: linkedIds.has(k.id) }));
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
+
// 工作空间 ↔ 知识库关联设置(R1 一期限制,明示避免误判为「同步丢了数据」):
|
|
195
|
+
// 本端点只写 `workspace_knowledge_bases` join 表,**不维护** KnowledgeBase.workspaceId
|
|
196
|
+
// 与 KnowledgeDocument.syncWorkspaceId ——而同步面的 scope 列正是后者(见
|
|
197
|
+
// sync-core 的 SYNC_TABLE_OVERRIDES:knowledge_bases.scope=workspaceId、
|
|
198
|
+
// knowledge_documents.scope=syncWorkspaceId)。因此:
|
|
199
|
+
// - 经本设置页关联的 KB **不进入同步面**(本机可见,不会上云、也不会被其他设备拉到);
|
|
200
|
+
// - 只有「创建工作空间时自动建立的 KB」走 workspaceId 绑定,才参与同步。
|
|
201
|
+
// 一期按设计限制收口(不在本端点做 scope 维护,避免隐式改归属语义);join 表
|
|
202
|
+
// 关联的 scope 维护留二期(届时需连带注册 workspace_knowledge_bases 或补冗余
|
|
203
|
+
// scope 列)。详见 sync-core/scripts/generate-registrations.mjs 的同一注记。
|
|
194
204
|
export const setWorkspaceKbsFn = createServerFn({ method: "POST" })
|
|
195
205
|
.middleware([authContextMiddleware])
|
|
196
206
|
.validator(z.object({ workspaceId: z.string(), kbIds: z.array(z.string()) }))
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// 归属转移核心(正向,桌面本地优先 P2-A8a)回归哨兵。mock prisma 下钉死六条
|
|
6
6
|
// 安全不变式(真库断言归 A13 集成实测):
|
|
7
7
|
// ① 属主 UPDATE 覆盖固定清单的每一张表且 where 恒为 fromUserId;
|
|
8
|
-
// ② AiProviderConfig.userId
|
|
9
|
-
//
|
|
8
|
+
// ② AiProviderConfig.userId 随身份翻转(C3 裁定:移出排除清单——不翻转的话
|
|
9
|
+
// 末尾 user.deleteMany 会经 ON DELETE CASCADE 把 AI 配置一起删掉),且该表
|
|
10
|
+
// 不在同步面(SYNC_TABLES 无 ai_provider_configs → 翻转不产出 outbox);
|
|
11
|
+
// ③ 云身份行建行(载荷含传入昵称/头像 + 用户级配置复制)+ guest 行 deleteMany 被调;
|
|
10
12
|
// ④ syncOutbox/syncRowState 零触达(转移开着触发器自然产出 op='upsert' +
|
|
11
13
|
// base_version=0,推送拓扑 A7 已修为 upsert 正向——实现若手碰同步两表即红);
|
|
12
14
|
// ⑤ 幂等:第二次调用 UPDATE 计数 0、guest 行 deleteMany 计 0(delete 会抛
|
|
@@ -17,18 +19,22 @@
|
|
|
17
19
|
// ⑧ probeCloudAccountStatusFn 三态——空账号 { empty: true } / 非空 { empty: false }
|
|
18
20
|
// / 探测失败(非 200、网络异常、形状漂移)一律 { empty: false, unreachable: true }
|
|
19
21
|
// (宁可不转不可错转)。
|
|
22
|
+
// C3 追加:⑨ AI 配置行翻转先于 guest 行删除(CASCADE 无从命中);反向转移把
|
|
23
|
+
// 云行的用户级配置列带回重建的 guest 行。
|
|
20
24
|
// 另有清单对齐断言:实现导出的属主清单与测试内固定清单逐项相等——schema 增删
|
|
21
25
|
// User-relation FK 时此测试红(防实现清单与 schema 漂移的哨兵)。
|
|
22
26
|
// @vitest-environment node
|
|
23
27
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
24
28
|
import type { ZodType } from 'zod'
|
|
29
|
+
import { SYNC_TABLE_MAP } from '@allbluecn/sync-core/registrations'
|
|
25
30
|
|
|
26
|
-
// ── 固定哨兵清单(schema.prisma @ a3adcc0a3 全量 User-relation FK 标量列 79
|
|
27
|
-
//
|
|
28
|
-
//
|
|
31
|
+
// ── 固定哨兵清单(schema.prisma @ a3adcc0a3 全量 User-relation FK 标量列 79 处;
|
|
32
|
+
// 扫描口径见 ownership-transfer.ts 头注释。C3 起 AiProviderConfig.userId 在内,
|
|
33
|
+
// TRANSFER_EXCLUDED 清零——79 处 = 翻转清单全长)────────────────────────────
|
|
29
34
|
const TEST_OWNERSHIP_COLUMNS: readonly string[] = [
|
|
30
35
|
'Account.userId',
|
|
31
36
|
'AgentConversation.userId',
|
|
37
|
+
'AiProviderConfig.userId',
|
|
32
38
|
'AiTask.createdById',
|
|
33
39
|
'AiUsageLog.userId',
|
|
34
40
|
'AnyHubApp.userId',
|
|
@@ -132,11 +138,34 @@ const touchable = () => ({
|
|
|
132
138
|
|
|
133
139
|
type TouchableMock = ReturnType<typeof touchable>
|
|
134
140
|
type DelegateMap = Record<string, { updateMany: UpdateManyFn }>
|
|
141
|
+
type UserRow = Record<string, unknown> & { id: string }
|
|
142
|
+
|
|
143
|
+
/** 用户级配置列样本(C3):aiSourceMode 故意为 null(不复制分支的哨兵) */
|
|
144
|
+
const GUEST_PREFERENCES = {
|
|
145
|
+
id: GUEST,
|
|
146
|
+
aiApiKey: 'sk-guest',
|
|
147
|
+
aiProvider: 'openrouter',
|
|
148
|
+
aiModel: 'openai/gpt-4o-mini',
|
|
149
|
+
aiSourceMode: null,
|
|
150
|
+
locale: 'zh',
|
|
151
|
+
timezone: 'Asia/Shanghai',
|
|
152
|
+
} as const
|
|
153
|
+
const CLOUD_PREFERENCES = {
|
|
154
|
+
id: CLOUD,
|
|
155
|
+
aiApiKey: 'sk-cloud',
|
|
156
|
+
aiProvider: 'openai',
|
|
157
|
+
locale: 'en',
|
|
158
|
+
} as const
|
|
159
|
+
|
|
135
160
|
type PrismaMock = DelegateMap & {
|
|
136
161
|
aiProviderConfig: { updateMany: UpdateManyFn }
|
|
137
162
|
syncOutbox: TouchableMock
|
|
138
163
|
syncRowState: TouchableMock
|
|
139
|
-
user: {
|
|
164
|
+
user: {
|
|
165
|
+
findUnique: UpdateManyFn
|
|
166
|
+
upsert: UpdateManyFn
|
|
167
|
+
deleteMany: UpdateManyFn
|
|
168
|
+
}
|
|
140
169
|
$transaction: (fn: (tx: unknown) => Promise<unknown>) => Promise<unknown>
|
|
141
170
|
/** A9:$executeRaw 只存在于事务闭包的 tx 上;外层 prisma 恒 undefined
|
|
142
171
|
* (实现若绕过事务直连外层 raw 清表,此处 undefined 直接 TypeError 红) */
|
|
@@ -155,13 +184,24 @@ function makePrismaMock(): PrismaMock {
|
|
|
155
184
|
// 表写在事务闭包外(污染窗口)即 TypeError 红(同闭包哨兵,A9)
|
|
156
185
|
const tx = {
|
|
157
186
|
...delegates,
|
|
158
|
-
// 排除类与同步两表:实现绝不以 delegate 触达,桩在此「能接住误触」
|
|
159
|
-
aiProviderConfig: { updateMany: vi.fn() },
|
|
160
187
|
syncOutbox: touchable(),
|
|
161
188
|
syncRowState: touchable(),
|
|
162
189
|
// deleteMany 首跑模拟真删(count=1);delete(单行)对已删行抛 P2025,
|
|
163
190
|
// 实现必须走 deleteMany——此处无 delete 桩,误用即 TypeError 红
|
|
164
|
-
|
|
191
|
+
// findUnique:C2 起云身份行统一走 ensureCloudIdentity(id 存在性 + email/
|
|
192
|
+
// username 冲突探测),默认 null = 空库首建;guest 行的用户级配置列样本
|
|
193
|
+
// 经 readUserPreferences 读出(C3)。云身份行默认「不存在」——反向转移用例
|
|
194
|
+
// 需要读到云行配置时用 mockCloudPreferences 显式注入。
|
|
195
|
+
user: {
|
|
196
|
+
findUnique: vi.fn(async (args?: { where?: Record<string, string> }) => {
|
|
197
|
+
const [field, value] = Object.entries(args?.where ?? {})[0] ?? []
|
|
198
|
+
if (field !== 'id') return null // email / username 冲突探测:恒空闲
|
|
199
|
+
if (value === GUEST) return GUEST_PREFERENCES
|
|
200
|
+
return null
|
|
201
|
+
}),
|
|
202
|
+
upsert: vi.fn(async () => ({})),
|
|
203
|
+
deleteMany: vi.fn(async () => ({ count: 1 })),
|
|
204
|
+
},
|
|
165
205
|
$executeRaw: vi.fn(async () => 1),
|
|
166
206
|
}
|
|
167
207
|
const prisma = { ...tx } as Record<string, unknown>
|
|
@@ -182,6 +222,18 @@ const setUpdateCounts = (prisma: PrismaMock, count: number) => {
|
|
|
182
222
|
}
|
|
183
223
|
}
|
|
184
224
|
|
|
225
|
+
/** 反向转移用例:让 readUserPreferences(CLOUD) 读到云身份行的用户级配置样本
|
|
226
|
+
* (正向路径的 ensureCloudIdentity 存在性探测仍返回 null = 空库首建) */
|
|
227
|
+
const mockCloudPreferences = (prisma: PrismaMock) => {
|
|
228
|
+
prisma.user.findUnique.mockImplementation(async (args?: { where?: Record<string, string> }) => {
|
|
229
|
+
const [field, value] = Object.entries(args?.where ?? {})[0] ?? []
|
|
230
|
+
if (field !== 'id') return null
|
|
231
|
+
if (value === GUEST) return GUEST_PREFERENCES
|
|
232
|
+
if (value === CLOUD) return CLOUD_PREFERENCES
|
|
233
|
+
return null
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
185
237
|
import {
|
|
186
238
|
OWNERSHIP_COLUMNS,
|
|
187
239
|
TRANSFER_EXCLUDED,
|
|
@@ -238,18 +290,37 @@ describe('transferOwnership(核心)', () => {
|
|
|
238
290
|
}
|
|
239
291
|
})
|
|
240
292
|
|
|
241
|
-
it('②AiProviderConfig
|
|
293
|
+
it('②AiProviderConfig 随身份翻转(不再排除)+ 不在同步面(翻转不上云)', async () => {
|
|
242
294
|
await transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME })
|
|
243
295
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
expect(
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
)
|
|
296
|
+
// C3:AI 配置行必须翻转(不翻转 → 末尾删 guest 行经 CASCADE 静默删掉配置)
|
|
297
|
+
expect(prisma.aiProviderConfig.updateMany).toHaveBeenCalledTimes(1)
|
|
298
|
+
expect(prisma.aiProviderConfig.updateMany).toHaveBeenCalledWith({
|
|
299
|
+
where: { userId: GUEST },
|
|
300
|
+
data: { userId: CLOUD },
|
|
301
|
+
})
|
|
302
|
+
// 排除清单已清零(口径位保留);实现清单里在内
|
|
303
|
+
expect(TRANSFER_EXCLUDED).toEqual([])
|
|
304
|
+
expect(OWNERSHIP_COLUMNS.some((c) => c.table === 'AiProviderConfig')).toBe(true)
|
|
305
|
+
// 复核同步面:ai_provider_configs 未注册 → 翻转不产出 change log / outbox
|
|
306
|
+
expect(SYNC_TABLE_MAP.has('ai_provider_configs')).toBe(false)
|
|
307
|
+
for (const regTable of SYNC_TABLE_MAP.keys()) {
|
|
308
|
+
expect(regTable).not.toBe('ai_provider_configs')
|
|
309
|
+
}
|
|
310
|
+
// 同步两表零触达(与既有 ④ 同口径,此处对 AI 配置翻转再钉一次)
|
|
311
|
+
expect(prisma.syncOutbox.updateMany).not.toHaveBeenCalled()
|
|
312
|
+
expect(prisma.syncRowState.updateMany).not.toHaveBeenCalled()
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('②b AI 配置翻转子序先于 guest 行删除(CASCADE 无从命中)', async () => {
|
|
316
|
+
await transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME })
|
|
317
|
+
|
|
318
|
+
const flipOrder = prisma.aiProviderConfig.updateMany.mock.invocationCallOrder[0]
|
|
319
|
+
const deleteOrder = (prisma.user.deleteMany as UpdateManyFn).mock.invocationCallOrder[0]
|
|
320
|
+
expect(flipOrder).toBeLessThan(deleteOrder)
|
|
250
321
|
})
|
|
251
322
|
|
|
252
|
-
it('
|
|
323
|
+
it('③云身份行建行(载荷含传入昵称/头像 + 用户级配置复制)+ guest 行 delete 被调', async () => {
|
|
253
324
|
await transferOwnership(prisma as never, GUEST, {
|
|
254
325
|
id: CLOUD,
|
|
255
326
|
name: CLOUD_NAME,
|
|
@@ -271,6 +342,15 @@ describe('transferOwnership(核心)', () => {
|
|
|
271
342
|
avatarConfig: CLOUD_AVATAR,
|
|
272
343
|
status: 'ACTIVE',
|
|
273
344
|
})
|
|
345
|
+
// C3:用户级配置列随身份复制(AI key 不丢;aiSourceMode=null 不复制)
|
|
346
|
+
expect(arg.create).toMatchObject({
|
|
347
|
+
aiApiKey: 'sk-guest',
|
|
348
|
+
aiProvider: 'openrouter',
|
|
349
|
+
aiModel: 'openai/gpt-4o-mini',
|
|
350
|
+
locale: 'zh',
|
|
351
|
+
timezone: 'Asia/Shanghai',
|
|
352
|
+
})
|
|
353
|
+
expect('aiSourceMode' in arg.create).toBe(false)
|
|
274
354
|
// password 随机占位(同 desktop-bootstrap 惯例:凭据登录必失败)
|
|
275
355
|
expect(typeof arg.create.password).toBe('string')
|
|
276
356
|
expect((arg.create.password as string).length).toBeGreaterThan(0)
|
|
@@ -295,6 +375,44 @@ describe('transferOwnership(核心)', () => {
|
|
|
295
375
|
).toEqual([])
|
|
296
376
|
})
|
|
297
377
|
|
|
378
|
+
it('④b R2 重置 row_state 版本基线:base_version=0 + dirty=true,同事务、翻转之后、删 guest 行之前', async () => {
|
|
379
|
+
await transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME })
|
|
380
|
+
|
|
381
|
+
// raw 只经事务闭包的 tx(外层 prisma 无 $executeRaw——绕过事务即 TypeError 红)
|
|
382
|
+
const raw = prisma.__tx!.$executeRaw
|
|
383
|
+
expect(raw).toHaveBeenCalledTimes(1)
|
|
384
|
+
const sql = String((raw.mock.calls[0] as [TemplateStringsArray])[0])
|
|
385
|
+
expect(sql).toContain('UPDATE sync_row_state')
|
|
386
|
+
expect(sql).toContain('base_version = 0')
|
|
387
|
+
expect(sql).toContain('dirty = true')
|
|
388
|
+
// 不删行:删掉后 pull 把「有行但无 row_state」视为非 dirty → 直接覆盖本地行
|
|
389
|
+
expect(sql).not.toContain('DELETE')
|
|
390
|
+
expect(prisma.$executeRaw).toBeUndefined()
|
|
391
|
+
|
|
392
|
+
// 执行序:全部属主翻转 → 基线重置 → 删 guest 行
|
|
393
|
+
const lastFlipOrder = Math.max(
|
|
394
|
+
...TEST_MODELS.flatMap((m) =>
|
|
395
|
+
((prisma[delegateOf(m)] as { updateMany: UpdateManyFn }).updateMany.mock
|
|
396
|
+
.invocationCallOrder as number[]),
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
const resetOrder = raw.mock.invocationCallOrder[0]
|
|
400
|
+
const deleteOrder = (prisma.user.deleteMany as UpdateManyFn).mock.invocationCallOrder[0]
|
|
401
|
+
expect(resetOrder).toBeGreaterThan(lastFlipOrder)
|
|
402
|
+
expect(resetOrder).toBeLessThan(deleteOrder)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('④c R2 幂等二跑同样执行重置(UPDATE 全表天然幂等,不抛错)', async () => {
|
|
406
|
+
await transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME })
|
|
407
|
+
setUpdateCounts(prisma, 0)
|
|
408
|
+
prisma.user.deleteMany.mockResolvedValue({ count: 0 })
|
|
409
|
+
|
|
410
|
+
await expect(
|
|
411
|
+
transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME }),
|
|
412
|
+
).resolves.toEqual({ tables: TEST_OWNERSHIP_COLUMNS.length, rows: 0 })
|
|
413
|
+
expect(prisma.__tx!.$executeRaw).toHaveBeenCalledTimes(2)
|
|
414
|
+
})
|
|
415
|
+
|
|
298
416
|
it('⑤幂等:重复调用 UPDATE 计数 0,返回 { tables, rows: 0 } 不抛错', async () => {
|
|
299
417
|
await transferOwnership(prisma as never, GUEST, { id: CLOUD, name: CLOUD_NAME })
|
|
300
418
|
|
|
@@ -394,24 +512,26 @@ describe('reverseTransferOwnership(A9 反向核心)', () => {
|
|
|
394
512
|
expect(upsertOrder).toBeLessThan(firstUpdateOrder)
|
|
395
513
|
})
|
|
396
514
|
|
|
397
|
-
it('③outbox/row_state
|
|
515
|
+
it('③outbox 清空 / row_state 不动(裁定 R2):单条 DELETE,且在闭包内于翻转后执行', async () => {
|
|
398
516
|
await reverseTransferOwnership(prisma as never, CLOUD, GUEST)
|
|
399
517
|
|
|
400
518
|
// raw 清表只经事务闭包的 tx(外层 prisma 无 $executeRaw——绕过事务即 TypeError 红)
|
|
401
519
|
const raw = prisma.__tx!.$executeRaw
|
|
402
|
-
expect(raw).toHaveBeenCalledTimes(
|
|
520
|
+
expect(raw).toHaveBeenCalledTimes(1)
|
|
403
521
|
const sqls = (raw.mock.calls as Array<[TemplateStringsArray]>).map((c) =>
|
|
404
522
|
String(c[0]),
|
|
405
523
|
)
|
|
406
524
|
expect(sqls.some((s) => s.includes('DELETE FROM sync_outbox'))).toBe(true)
|
|
407
|
-
|
|
525
|
+
// 裁定 R2:row_state 保留(清掉会让重登后首推 base_version 全 0 → 全量
|
|
526
|
+
// versionDiverged;outbox 是「待推 op」的唯一载体,清它就够)
|
|
527
|
+
expect(sqls.some((s) => s.includes('DELETE FROM sync_row_state'))).toBe(false)
|
|
408
528
|
expect(prisma.$executeRaw).toBeUndefined()
|
|
409
529
|
|
|
410
530
|
// 同一事务:$transaction 恰一次(无第二事务 / 无事务外窗口)
|
|
411
531
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
|
412
532
|
|
|
413
|
-
// 闭包内执行序:guest 行重建 → 属主翻转 → 云行删除 →
|
|
414
|
-
//
|
|
533
|
+
// 闭包内执行序:guest 行重建 → 属主翻转 → 云行删除 → 清 outbox(清空后引擎
|
|
534
|
+
// 无从产出反向 op——审查重点「防反向 op 推上云污染」)
|
|
415
535
|
const rawOrder = raw.mock.invocationCallOrder
|
|
416
536
|
const lastUpdateOrder = Math.max(
|
|
417
537
|
...(prisma.story.updateMany as UpdateManyFn).mock.invocationCallOrder,
|
|
@@ -421,22 +541,42 @@ describe('reverseTransferOwnership(A9 反向核心)', () => {
|
|
|
421
541
|
expect(Math.min(...rawOrder)).toBeGreaterThan(deleteOrder)
|
|
422
542
|
})
|
|
423
543
|
|
|
424
|
-
it('④AiProviderConfig
|
|
544
|
+
it('④AiProviderConfig 反向翻转 + 云行用户级配置复制到重建的 guest 行(C3)', async () => {
|
|
545
|
+
mockCloudPreferences(prisma)
|
|
425
546
|
await reverseTransferOwnership(prisma as never, CLOUD, GUEST)
|
|
426
|
-
|
|
547
|
+
|
|
548
|
+
// AI 配置行随身份回 guest(同正向;不翻转会被云行删除级联带走)
|
|
549
|
+
expect(prisma.aiProviderConfig.updateMany).toHaveBeenCalledWith({
|
|
550
|
+
where: { userId: CLOUD },
|
|
551
|
+
data: { userId: GUEST },
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
// guest 行重建时带走云态填的配置(AI key / 语言等;只在 create 分支生效)
|
|
555
|
+
const arg = (prisma.user.upsert as UpdateManyFn).mock.calls[0][0] as {
|
|
556
|
+
create: Record<string, unknown>
|
|
557
|
+
update: Record<string, unknown>
|
|
558
|
+
}
|
|
559
|
+
expect(arg.create).toMatchObject({
|
|
560
|
+
id: GUEST,
|
|
561
|
+
aiApiKey: 'sk-cloud',
|
|
562
|
+
aiProvider: 'openai',
|
|
563
|
+
locale: 'en',
|
|
564
|
+
})
|
|
565
|
+
// 幂等语义不变:update 仍留空(已存在的影子行保留本机值)
|
|
566
|
+
expect(arg.update).toEqual({})
|
|
427
567
|
})
|
|
428
568
|
|
|
429
|
-
it('⑤幂等二跑:UPDATE 计 0、deleteMany 计 0、raw
|
|
569
|
+
it('⑤幂等二跑:UPDATE 计 0、deleteMany 计 0、raw 清 outbox 照常,返回 rows=0 不抛错', async () => {
|
|
430
570
|
await reverseTransferOwnership(prisma as never, CLOUD, GUEST)
|
|
431
571
|
|
|
432
572
|
// 二跑:属主列 where cloudUserId 匹配 0 行(已回 guest);云身份行已删(deleteMany
|
|
433
|
-
// 计 0 no-op);guest 行 upsert 幂等(update 留空);raw
|
|
573
|
+
// 计 0 no-op);guest 行 upsert 幂等(update 留空);raw 清 outbox 照常执行
|
|
434
574
|
setUpdateCounts(prisma, 0)
|
|
435
575
|
prisma.user.deleteMany.mockResolvedValue({ count: 0 })
|
|
436
576
|
await expect(
|
|
437
577
|
reverseTransferOwnership(prisma as never, CLOUD, GUEST),
|
|
438
578
|
).resolves.toEqual({ tables: TEST_OWNERSHIP_COLUMNS.length, rows: 0 })
|
|
439
|
-
expect(prisma.__tx!.$executeRaw).toHaveBeenCalledTimes(
|
|
579
|
+
expect(prisma.__tx!.$executeRaw).toHaveBeenCalledTimes(2) // 1(首跑)+ 1(二跑)
|
|
440
580
|
})
|
|
441
581
|
|
|
442
582
|
it('⑥审查重点(mock 翻译):反向转移后 guest 对核心表读写可用——核心五表清单对齐 + 幂等二跑', async () => {
|
|
@@ -495,7 +635,6 @@ vi.mock('@allbluecn/database', () => ({
|
|
|
495
635
|
const sfMocks = vi.hoisted(() => ({
|
|
496
636
|
session: null as null | { user: { id: string } },
|
|
497
637
|
remoteAuth: null as null | { userId: string; sessionToken?: string },
|
|
498
|
-
accountEmpty: true,
|
|
499
638
|
prisma: null as unknown,
|
|
500
639
|
checkAuthRateLimit: vi.fn(),
|
|
501
640
|
clearRemoteAuth: vi.fn(),
|
|
@@ -578,9 +717,8 @@ vi.mock('@/server/remote-auth', () => ({
|
|
|
578
717
|
readRemoteAuth: async () => sfMocks.remoteAuth,
|
|
579
718
|
clearRemoteAuth: sfMocks.clearRemoteAuth,
|
|
580
719
|
}))
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}))
|
|
720
|
+
// I2:空账号复核改云端权威(SaaS /api/sync/account-status),本地 isAccountEmpty
|
|
721
|
+
// 不再参与转移判定——下面 transferFn 用例全部经 fetchMock 注入云端三态。
|
|
584
722
|
vi.mock('@/server/middlewares/rate-limit', () => ({
|
|
585
723
|
checkAuthRateLimit: sfMocks.checkAuthRateLimit,
|
|
586
724
|
}))
|
|
@@ -602,25 +740,60 @@ const probeFn = probeCloudAccountStatusFn as unknown as () => Promise<{
|
|
|
602
740
|
unreachable?: boolean
|
|
603
741
|
}>
|
|
604
742
|
|
|
605
|
-
// SaaS fetch stub(saas-profile.ts /
|
|
743
|
+
// SaaS fetch stub(saas-profile.ts / account-status-cloud.ts 消费;vi.stubGlobal
|
|
606
744
|
// 拦截,不真出网——惯例照 exchange-remote-token.test.ts)
|
|
607
745
|
const fetchMock = vi.hoisted(() => vi.fn())
|
|
608
746
|
|
|
747
|
+
interface FetchRoute {
|
|
748
|
+
ok?: boolean
|
|
749
|
+
status?: number
|
|
750
|
+
body?: unknown
|
|
751
|
+
/** true = 模拟网络异常(fetch reject) */
|
|
752
|
+
reject?: boolean
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* 按 URL 分流 stub:account-status(云端空账号探测,I2)与 desktop-callback
|
|
757
|
+
* (profile 自取)两个端点。缺省不传即「该端点不应被调」(调用即抛错)。
|
|
758
|
+
*/
|
|
759
|
+
function stubSaasFetch(routes: { accountStatus?: FetchRoute; profile?: FetchRoute }) {
|
|
760
|
+
fetchMock.mockImplementation(async (url: unknown) => {
|
|
761
|
+
const target = String(url)
|
|
762
|
+
const route = target.includes('/api/sync/account-status')
|
|
763
|
+
? routes.accountStatus
|
|
764
|
+
: target.includes('/api/auth/desktop-callback')
|
|
765
|
+
? routes.profile
|
|
766
|
+
: undefined
|
|
767
|
+
if (!route) throw new Error(`unexpected fetch: ${target}`)
|
|
768
|
+
if (route.reject) throw new Error('ECONNREFUSED')
|
|
769
|
+
return {
|
|
770
|
+
ok: route.ok,
|
|
771
|
+
status: route.status ?? (route.ok ? 200 : 503),
|
|
772
|
+
json: async () => route.body,
|
|
773
|
+
}
|
|
774
|
+
})
|
|
775
|
+
}
|
|
776
|
+
|
|
609
777
|
describe('transferOwnershipFn(serverFn 校验)', () => {
|
|
610
778
|
beforeEach(() => {
|
|
611
779
|
sfMocks.session = { user: { id: GUEST } }
|
|
612
780
|
sfMocks.remoteAuth = { userId: CLOUD, sessionToken: 'tok-123' }
|
|
613
|
-
sfMocks.accountEmpty = true
|
|
614
781
|
sfMocks.prisma = makePrismaMock()
|
|
615
782
|
setUpdateCounts(sfMocks.prisma as PrismaMock, 1)
|
|
616
783
|
fetchMock.mockReset()
|
|
784
|
+
// 默认:云端权威探测 = 空账号(转移放行)
|
|
785
|
+
stubSaasFetch({
|
|
786
|
+
accountStatus: { ok: true, body: { empty: true } },
|
|
787
|
+
profile: { ok: true, body: SAAS_PROFILE },
|
|
788
|
+
})
|
|
789
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
617
790
|
})
|
|
618
791
|
|
|
619
792
|
afterEach(() => {
|
|
620
793
|
vi.unstubAllGlobals()
|
|
621
794
|
})
|
|
622
795
|
|
|
623
|
-
it('⑥remoteAuth.userId ≠ data.cloudUserId →
|
|
796
|
+
it('⑥remoteAuth.userId ≠ data.cloudUserId → 抛错,零触库零出网', async () => {
|
|
624
797
|
sfMocks.remoteAuth = { userId: 'attacker-other' }
|
|
625
798
|
|
|
626
799
|
await expect(
|
|
@@ -630,6 +803,7 @@ describe('transferOwnershipFn(serverFn 校验)', () => {
|
|
|
630
803
|
const p = sfMocks.prisma as PrismaMock
|
|
631
804
|
expect(p.story.updateMany).not.toHaveBeenCalled()
|
|
632
805
|
expect(p.user.upsert).not.toHaveBeenCalled()
|
|
806
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
633
807
|
})
|
|
634
808
|
|
|
635
809
|
it('无 remoteAuth(非 desktop / 未交换登录)→ 抛错', async () => {
|
|
@@ -641,13 +815,44 @@ describe('transferOwnershipFn(serverFn 校验)', () => {
|
|
|
641
815
|
expect((sfMocks.prisma as PrismaMock).story.updateMany).not.toHaveBeenCalled()
|
|
642
816
|
})
|
|
643
817
|
|
|
644
|
-
it('
|
|
645
|
-
|
|
818
|
+
it('I2 云端权威:账号非空(account-status.empty=false)→ 抛错拒绝转移', async () => {
|
|
819
|
+
stubSaasFetch({ accountStatus: { ok: true, body: { empty: false } } })
|
|
646
820
|
|
|
647
821
|
await expect(
|
|
648
822
|
transferFn({ data: { cloudUserId: CLOUD, name: CLOUD_NAME } }),
|
|
649
823
|
).rejects.toThrow(/空账号|非空/)
|
|
650
824
|
expect((sfMocks.prisma as PrismaMock).story.updateMany).not.toHaveBeenCalled()
|
|
825
|
+
// 判定来自云端端点(带云会话 token cookie),不是本地库计数
|
|
826
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, { headers: { Cookie: string } }]
|
|
827
|
+
expect(url).toContain('/api/sync/account-status')
|
|
828
|
+
expect(init.headers.Cookie).toBe('auth.session-token=tok-123')
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
it('I2 云端权威:探测不可达(网络异常 / 非 200 / 形状漂移)→ 拒绝转移', async () => {
|
|
832
|
+
stubSaasFetch({ accountStatus: { reject: true } })
|
|
833
|
+
await expect(
|
|
834
|
+
transferFn({ data: { cloudUserId: CLOUD, name: CLOUD_NAME } }),
|
|
835
|
+
).rejects.toThrow(/不可达|状态/)
|
|
836
|
+
expect((sfMocks.prisma as PrismaMock).story.updateMany).not.toHaveBeenCalled()
|
|
837
|
+
|
|
838
|
+
stubSaasFetch({ accountStatus: { ok: false, status: 503 } })
|
|
839
|
+
await expect(
|
|
840
|
+
transferFn({ data: { cloudUserId: CLOUD, name: CLOUD_NAME } }),
|
|
841
|
+
).rejects.toThrow(/不可达|状态/)
|
|
842
|
+
|
|
843
|
+
stubSaasFetch({ accountStatus: { ok: true, body: { empty: 'yes' } } })
|
|
844
|
+
await expect(
|
|
845
|
+
transferFn({ data: { cloudUserId: CLOUD, name: CLOUD_NAME } }),
|
|
846
|
+
).rejects.toThrow(/不可达|状态/)
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
it('I2 幂等语义:转移后再次进入(本地已有数据)仍按云端判定放行', async () => {
|
|
850
|
+
// 本地 mock 的 updateMany 一律返回 count=1(等价「本地已有转移后数据」),
|
|
851
|
+
// 云端仍报告空账号 → 允许重复执行(转移本身幂等,UPDATE 匹配 0 行)
|
|
852
|
+
const result = (await transferFn({
|
|
853
|
+
data: { cloudUserId: CLOUD, name: CLOUD_NAME },
|
|
854
|
+
})) as { tables: number }
|
|
855
|
+
expect(result.tables).toBe(TEST_OWNERSHIP_COLUMNS.length)
|
|
651
856
|
})
|
|
652
857
|
|
|
653
858
|
it('正向:guest 会话 + remoteAuth 匹配 + 空账号 → 以 GUEST→cloudUserId 转移并回传结果', async () => {
|
|
@@ -682,7 +887,6 @@ describe('reverseTransferOwnershipFn(A9 serverFn 校验)', () => {
|
|
|
682
887
|
beforeEach(() => {
|
|
683
888
|
sfMocks.session = { user: { id: CLOUD } }
|
|
684
889
|
sfMocks.remoteAuth = { userId: CLOUD, sessionToken: 'tok-123' }
|
|
685
|
-
sfMocks.accountEmpty = true
|
|
686
890
|
sfMocks.prisma = makePrismaMock()
|
|
687
891
|
setUpdateCounts(sfMocks.prisma as PrismaMock, 1)
|
|
688
892
|
sfMocks.clearRemoteAuth.mockClear()
|
|
@@ -770,10 +974,13 @@ describe('transferOwnershipFn 身份字段省略(A8b 自取 SaaS profile)',
|
|
|
770
974
|
beforeEach(() => {
|
|
771
975
|
sfMocks.session = { user: { id: GUEST } }
|
|
772
976
|
sfMocks.remoteAuth = { userId: CLOUD, sessionToken: 'tok-123' }
|
|
773
|
-
sfMocks.accountEmpty = true
|
|
774
977
|
sfMocks.prisma = makePrismaMock()
|
|
775
978
|
setUpdateCounts(sfMocks.prisma as PrismaMock, 1)
|
|
776
979
|
fetchMock.mockReset()
|
|
980
|
+
stubSaasFetch({
|
|
981
|
+
accountStatus: { ok: true, body: { empty: true } },
|
|
982
|
+
profile: { ok: true, body: SAAS_PROFILE },
|
|
983
|
+
})
|
|
777
984
|
vi.stubGlobal('fetch', fetchMock)
|
|
778
985
|
})
|
|
779
986
|
|
|
@@ -782,16 +989,17 @@ describe('transferOwnershipFn 身份字段省略(A8b 自取 SaaS profile)',
|
|
|
782
989
|
})
|
|
783
990
|
|
|
784
991
|
it('name 省略 → 以 remoteAuth.sessionToken 自取 profile,upsert 载荷携带自取资料', async () => {
|
|
785
|
-
fetchMock.mockResolvedValue({ ok: true, json: async () => SAAS_PROFILE })
|
|
786
|
-
|
|
787
992
|
const result = (await transferFn({ data: { cloudUserId: CLOUD } })) as { rows: number }
|
|
788
993
|
expect(result.rows).toBe(TEST_OWNERSHIP_COLUMNS.length)
|
|
789
994
|
|
|
790
995
|
// 自取请求:SaaS exchange 端点 + 云会话 token cookie(照 exchange 先例)
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
996
|
+
// I2 起首次请求是 account-status 探测(云端权威),profile 为第二次
|
|
997
|
+
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
998
|
+
const profileCall = (fetchMock.mock.calls as Array<[string, { headers: { Cookie: string } }]>).find(
|
|
999
|
+
([url]) => url.includes('/api/auth/desktop-callback?exchange=true'),
|
|
1000
|
+
)
|
|
1001
|
+
expect(profileCall).toBeDefined()
|
|
1002
|
+
expect(profileCall![1].headers.Cookie).toBe('auth.session-token=tok-123')
|
|
795
1003
|
|
|
796
1004
|
// 云身份行 upsert:昵称/邮箱/头像来自 SaaS(缺 name 兜底 = cloudUserId)
|
|
797
1005
|
const arg = (sfMocks.prisma as PrismaMock).user.upsert.mock.calls[0][0] as {
|
|
@@ -807,11 +1015,13 @@ describe('transferOwnershipFn 身份字段省略(A8b 自取 SaaS profile)',
|
|
|
807
1015
|
})
|
|
808
1016
|
})
|
|
809
1017
|
|
|
810
|
-
it('字段全传 →
|
|
1018
|
+
it('字段全传 → 用传入值,不请求 profile(A8a 调用形态向后兼容)', async () => {
|
|
811
1019
|
await transferFn({
|
|
812
1020
|
data: { cloudUserId: CLOUD, name: CLOUD_NAME, email: CLOUD_EMAIL, avatarConfig: CLOUD_AVATAR },
|
|
813
1021
|
})
|
|
814
|
-
|
|
1022
|
+
// I2 起必有 account-status 探测;但身份字段全传时不得再打 profile 端点
|
|
1023
|
+
const urls = (fetchMock.mock.calls as Array<[string]>).map(([url]) => url)
|
|
1024
|
+
expect(urls.some((u) => u.includes('/api/auth/desktop-callback'))).toBe(false)
|
|
815
1025
|
const arg = (sfMocks.prisma as PrismaMock).user.upsert.mock.calls[0][0] as {
|
|
816
1026
|
create: Record<string, unknown>
|
|
817
1027
|
}
|
|
@@ -823,7 +1033,10 @@ describe('transferOwnershipFn 身份字段省略(A8b 自取 SaaS profile)',
|
|
|
823
1033
|
})
|
|
824
1034
|
|
|
825
1035
|
it('name 省略且 SaaS 资料获取失败 → 抛错走 bind:fail,零触库', async () => {
|
|
826
|
-
|
|
1036
|
+
stubSaasFetch({
|
|
1037
|
+
accountStatus: { ok: true, body: { empty: true } },
|
|
1038
|
+
profile: { ok: false, status: 503 },
|
|
1039
|
+
})
|
|
827
1040
|
|
|
828
1041
|
await expect(transferFn({ data: { cloudUserId: CLOUD } })).rejects.toThrow(/SaaS/)
|
|
829
1042
|
const p = sfMocks.prisma as PrismaMock
|
|
@@ -833,7 +1046,7 @@ describe('transferOwnershipFn 身份字段省略(A8b 自取 SaaS profile)',
|
|
|
833
1046
|
|
|
834
1047
|
it('name 省略且无 sessionToken → 抛错,零出网零触库', async () => {
|
|
835
1048
|
sfMocks.remoteAuth = { userId: CLOUD }
|
|
836
|
-
await expect(transferFn({ data: { cloudUserId: CLOUD } })).rejects.toThrow(/token
|
|
1049
|
+
await expect(transferFn({ data: { cloudUserId: CLOUD } })).rejects.toThrow(/token|状态|不可达/)
|
|
837
1050
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
838
1051
|
expect((sfMocks.prisma as PrismaMock).story.updateMany).not.toHaveBeenCalled()
|
|
839
1052
|
})
|