@allbluecn/web-app 0.23.6 → 0.23.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.23.6",
3
+ "version": "0.23.7",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,185 @@
1
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
2
+ import type React from 'react'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ // 桌面登出分支回归哨兵(A9 反向转移编排 + 会话来源判别):
6
+ // /api/auth/session 改为会回落桌面身份并带 desktopSession 标记后,clientSession
7
+ // 不再为空——旧判据 `bridge && !clientSession` 会把云态登出错走成 authjs signOut
8
+ // (数据留在云属主,桌面再也回不到 guest)。本文件钉死三条分支与编排顺序:
9
+ // ① desktopSession=guest → 本地 guest 登出(不反向转移)
10
+ // ② 无标记(本地 authjs 凭据登录)→ signOut(不反向转移)
11
+ // ③ desktopSession=cloud → 反向转移编排(lock begin → reverse → clear → lock end)
12
+ const H = vi.hoisted(() => ({
13
+ reload: vi.fn(),
14
+ queryClient: { clear: vi.fn(), resetQueries: vi.fn() },
15
+ reverseTransferOwnershipFn: vi.fn(async () => ({ tables: 1, rows: 1 })),
16
+ logoutRemoteFn: vi.fn(async () => ({ success: true })),
17
+ signOut: vi.fn(async () => ({ success: true })),
18
+ transferLock: vi.fn(async (_action?: 'begin' | 'end') => ({ ok: true })),
19
+ clearCloudSession: vi.fn(async () => ({ ok: true })),
20
+ // 由各用例覆盖:useSession() 的返回值
21
+ session: null as unknown,
22
+ }))
23
+
24
+ const CLOUD_SESSION = {
25
+ user: { id: 'cloud-1', name: '云用户', email: 'cloud@example.com', role: 'USER', isGuest: false },
26
+ expires: 'never',
27
+ desktopSession: 'cloud' as const,
28
+ }
29
+ const GUEST_SESSION = {
30
+ user: { id: 'local-guest', name: '本地用户', email: 'guest@localhost', role: 'USER', isGuest: true },
31
+ expires: 'never',
32
+ desktopSession: 'guest' as const,
33
+ }
34
+ const LOCAL_CREDENTIAL_SESSION = {
35
+ user: { id: 'authjs-user-1', name: '本地账号', email: 'local@example.com', role: 'USER', isGuest: false },
36
+ expires: '2026-12-31T00:00:00.000Z',
37
+ }
38
+
39
+ vi.mock('@/lib/auth/auth-client', () => ({
40
+ useSession: () => ({ data: H.session }),
41
+ signOut: H.signOut,
42
+ }))
43
+ vi.mock('@/lib/query-client-seam', () => ({
44
+ getQueryClient: () => H.queryClient,
45
+ }))
46
+ vi.mock('@/components/ui/sidebar', () => ({
47
+ SidebarMenu: ({ children }: { children?: React.ReactNode }) => <ul>{children}</ul>,
48
+ SidebarMenuItem: ({ children }: { children?: React.ReactNode }) => <li>{children}</li>,
49
+ SidebarMenuButton: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) => (
50
+ <button onClick={onClick}>{children}</button>
51
+ ),
52
+ useSidebar: () => ({ isMobile: false }),
53
+ }))
54
+ vi.mock('@/components/ui/toast', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
55
+ vi.mock('@/server/serverFns/auth/session', () => ({ logoutRemoteFn: H.logoutRemoteFn }))
56
+ vi.mock('@/server/serverFns/sync/transfer', () => ({
57
+ reverseTransferOwnershipFn: H.reverseTransferOwnershipFn,
58
+ }))
59
+ vi.mock('@/lib/desktop-bridge-types', () => ({
60
+ getDesktopBridge: () => ({
61
+ transferLock: H.transferLock,
62
+ clearCloudSession: H.clearCloudSession,
63
+ }),
64
+ }))
65
+ vi.mock('@/components/shared-kit', () => ({
66
+ UserAvatar: ({ name }: { name?: string }) => <div data-testid="avatar">{name}</div>,
67
+ }))
68
+ vi.mock('@/components/magicui/sparkles-text', () => ({
69
+ SparklesText: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
70
+ }))
71
+ vi.mock('@/components/ui/dropdown-menu', () => ({
72
+ DropdownMenu: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
73
+ DropdownMenuTrigger: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
74
+ DropdownMenuContent: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
75
+ DropdownMenuGroup: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
76
+ DropdownMenuItem: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) => (
77
+ <button type="button" onClick={onClick}>
78
+ {children}
79
+ </button>
80
+ ),
81
+ DropdownMenuLabel: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
82
+ DropdownMenuSeparator: () => null,
83
+ }))
84
+ vi.mock('@tanstack/react-router', () => ({
85
+ useRouter: () => ({
86
+ navigate: vi.fn(),
87
+ state: { location: { pathname: '/dashboard', searchStr: '' } },
88
+ }),
89
+ useMatches: () => [],
90
+ }))
91
+
92
+ async function clickLogout() {
93
+ const { UserMenu } = await import('../user-menu')
94
+ render(<UserMenu />)
95
+ fireEvent.click(screen.getByText('退出登录'))
96
+ }
97
+
98
+ /** transferLock 某次调用的 invocationCallOrder(未调用 → -1) */
99
+ function lockOrder(action: 'begin' | 'end'): number {
100
+ const idx = H.transferLock.mock.calls.findIndex((call) => call[0] === action)
101
+ return H.transferLock.mock.invocationCallOrder[idx] ?? -1
102
+ }
103
+
104
+ describe('UserMenu 桌面登出分支', () => {
105
+ beforeEach(() => {
106
+ vi.clearAllMocks()
107
+ try {
108
+ Object.defineProperty(window, 'location', {
109
+ configurable: true,
110
+ value: { ...window.location, reload: H.reload },
111
+ })
112
+ } catch {
113
+ /* jsdom 限制:后续断言只看转移链路 */
114
+ }
115
+ })
116
+
117
+ afterEach(() => {
118
+ cleanup()
119
+ })
120
+
121
+ it('③ desktopSession=cloud:走反向转移编排,不走 authjs signOut,最后整页 reload', async () => {
122
+ H.session = CLOUD_SESSION
123
+ await clickLogout()
124
+
125
+ await waitFor(() => {
126
+ expect(H.reverseTransferOwnershipFn).toHaveBeenCalledWith({ data: { cloudUserId: 'cloud-1' } })
127
+ })
128
+ expect(H.clearCloudSession).toHaveBeenCalledTimes(1)
129
+ expect(H.logoutRemoteFn).toHaveBeenCalledWith({ data: {} })
130
+ expect(H.queryClient.clear).toHaveBeenCalledTimes(1)
131
+ expect(H.signOut).not.toHaveBeenCalled()
132
+ // ④ 整页 reload(reload 后 getSessionFn 回落 guest 会话)
133
+ expect(H.reload).toHaveBeenCalledTimes(1)
134
+ }, 20000)
135
+
136
+ it('③ 编排顺序:lock begin → reverse → clearCloudSession → lock end → reload', async () => {
137
+ H.session = CLOUD_SESSION
138
+ await clickLogout()
139
+
140
+ await waitFor(() => {
141
+ expect(H.reload).toHaveBeenCalledTimes(1)
142
+ })
143
+
144
+ const begin = lockOrder('begin')
145
+ const end = lockOrder('end')
146
+ const reverse = H.reverseTransferOwnershipFn.mock.invocationCallOrder[0]!
147
+ const clear = H.clearCloudSession.mock.invocationCallOrder[0]!
148
+ const reload = H.reload.mock.invocationCallOrder[0]!
149
+
150
+ expect(begin).toBeGreaterThan(0)
151
+ expect(begin).toBeLessThan(reverse)
152
+ expect(reverse).toBeLessThan(clear)
153
+ // 先清云会话再解锁:引擎恢复 tick 前云会话必须已不可用
154
+ expect(clear).toBeLessThan(end)
155
+ expect(end).toBeLessThan(reload)
156
+ }, 20000)
157
+
158
+ it('① desktopSession=guest(local-guest):本地 guest 登出,不触发反向转移', async () => {
159
+ H.session = GUEST_SESSION
160
+ await clickLogout()
161
+
162
+ await waitFor(() => {
163
+ expect(H.logoutRemoteFn).toHaveBeenCalledWith({ data: {} })
164
+ })
165
+ expect(H.reverseTransferOwnershipFn).not.toHaveBeenCalled()
166
+ expect(H.transferLock).not.toHaveBeenCalled()
167
+ expect(H.clearCloudSession).not.toHaveBeenCalled()
168
+ expect(H.queryClient.resetQueries).toHaveBeenCalled()
169
+ expect(H.reload).toHaveBeenCalledTimes(1)
170
+ expect(H.signOut).not.toHaveBeenCalled()
171
+ }, 20000)
172
+
173
+ it('② 本地 authjs 凭据登录(无 desktopSession 标记):走 signOut,不触发反向转移', async () => {
174
+ H.session = LOCAL_CREDENTIAL_SESSION
175
+ await clickLogout()
176
+
177
+ await waitFor(() => {
178
+ expect(H.signOut).toHaveBeenCalledTimes(1)
179
+ })
180
+ expect(H.reverseTransferOwnershipFn).not.toHaveBeenCalled()
181
+ expect(H.transferLock).not.toHaveBeenCalled()
182
+ expect(H.clearCloudSession).not.toHaveBeenCalled()
183
+ expect(H.reload).not.toHaveBeenCalled()
184
+ }, 20000)
185
+ })
@@ -23,11 +23,10 @@ import {
23
23
  Settings,
24
24
  Users,
25
25
  } from "lucide-react";
26
- import type { QueryClient } from "@tanstack/react-query";
27
26
  import { logoutRemoteFn } from '@/server/serverFns/auth/session'
28
27
  import { reverseTransferOwnershipFn } from '@/server/serverFns/sync/transfer'
29
28
  import { getDesktopBridge } from '@/lib/desktop-bridge-types'
30
- import type { DesktopBridgeApi } from '@/lib/desktop-bridge-types'
29
+ import { runDesktopLogout } from '@/lib/desktop-logout'
31
30
  import { toast } from '@/components/ui/toast'
32
31
 
33
32
  import {
@@ -54,53 +53,29 @@ import { SparklesText } from "@/components/magicui/sparkles-text";
54
53
  import { m } from "@/paraglide/messages.js";
55
54
 
56
55
  /**
57
- * 桌面云态登出(桌面本地优先 P2-A9 反向转移编排,裁定顺序):登出 = 把云身份名下
58
- * 数据翻回 guest(数据保留本机),双会话清理后回到 guest 态。
59
- * ① 置转移锁(transferLock,复用 bind 锁文件——引擎每 tick 判定同源,「转移进行中
60
- * 引擎勿 tick」;旧壳无此 IPC 优雅降级:反向转移单事务自身原子,锁为双保险);
61
- * ② reverseTransferOwnershipFn(serverFn:校验 remoteAuth/会话本人 → 单事务
62
- * cloud→guest + outbox/row_state 同事务清空防反向 op 上云 + 重建 guest 行;
63
- * 事务提交后 server 侧已清 remote-auth——崩窗口收敛,A8b 发现 2 的镜像);
64
- * ③ 双会话清理:logoutRemoteFn(幂等 re-clear remote-auth)+ clearCloudSession
65
- * (清 cloud-session.json,引擎 guest 守卫回 idle)→ 清锁(先清云会话再解锁,
66
- * 引擎恢复 tick 前云会话已不可用);
67
- * ④ queryClient.clear() + 整页 reload——reload 后 getSessionFn 回落 guest 会话。
68
- * 失败路径(锁失败 / 转移失败):解锁 + toast sync_logout_failed + 不 reload(留在
69
- * 云态可重试;转移失败单事务已回滚,数据仍是云属主,重试幂等)。模块内局部函数
70
- * (非导出):本文件唯一导出是 UserMenu 组件,勿破坏 Fast Refresh 纯组件边界。
56
+ * 桌面云态登出(P2-A9 反向转移):编排本体在 `@/lib/desktop-logout`(可测纯函数,
57
+ * 失败路径/调用顺序由单测钉死),此处只做**依赖装配**——bridge IPC、serverFn、
58
+ * queryClient 清空与整页 reload。顺序与失败语义见该模块文件头注释。
71
59
  */
72
60
  async function desktopCloudLogout(
73
- bridge: DesktopBridgeApi,
61
+ bridge: NonNullable<ReturnType<typeof getDesktopBridge>>,
74
62
  cloudUserId: string,
75
- queryClient: QueryClient,
63
+ queryClient: { clear: () => void },
76
64
  ): Promise<void> {
77
- toast.info(m.sync_logout_in_progress());
78
- // ① 转移锁
79
- let lock: { ok: boolean; error?: string } = { ok: true };
80
- try {
81
- lock = bridge.transferLock ? await bridge.transferLock("begin") : { ok: true };
82
- } catch {
83
- lock = { ok: false };
84
- }
85
- if (!lock.ok) {
86
- toast.error(m.sync_logout_failed());
87
- return;
88
- }
89
- // ② 反向转移(失败:单事务回滚留在云态,解锁可重试)
90
- try {
91
- await reverseTransferOwnershipFn({ data: { cloudUserId } });
92
- } catch {
93
- try { await bridge.transferLock?.("end"); } catch { /* 尽力而为 */ }
94
- toast.error(m.sync_logout_failed());
95
- return;
96
- }
97
- // ③ 双会话清理(均幂等 best-effort:serverFn 已清 remote-auth,此处兜底)
98
- try { await logoutRemoteFn({ data: {} }); } catch { /* 尽力而为 */ }
99
- try { await bridge.clearCloudSession?.(); } catch { /* 尽力而为 */ }
100
- try { await bridge.transferLock?.("end"); } catch { /* 尽力而为 */ }
101
- // ④ 缓存清空 + 整页刷新(reload 后 desktop-state 回 guest)
102
- queryClient.clear();
103
- window.location.reload();
65
+ await runDesktopLogout(cloudUserId, {
66
+ // 旧壳无 transferLock IPC:省略 = 置锁成功 / 解锁 no-op(反向转移单事务自身原子)
67
+ lock: bridge.transferLock ? () => bridge.transferLock!("begin") : undefined,
68
+ unlock: bridge.transferLock ? () => bridge.transferLock!("end") : undefined,
69
+ transfer: (id) => reverseTransferOwnershipFn({ data: { cloudUserId: id } }),
70
+ logoutRemote: () => logoutRemoteFn({ data: {} }),
71
+ clearCloudSession: bridge.clearCloudSession ? () => bridge.clearCloudSession!() : undefined,
72
+ clearQueries: () => queryClient.clear(),
73
+ reload: () => window.location.reload(),
74
+ notify: (notice) => {
75
+ if (notice === "in-progress") toast.info(m.sync_logout_in_progress());
76
+ else toast.error(m.sync_logout_failed());
77
+ },
78
+ });
104
79
  }
105
80
 
106
81
  export function UserMenu() {
@@ -137,12 +112,14 @@ export function UserMenu() {
137
112
  window.location.reload();
138
113
  return;
139
114
  }
140
- // 桌面云态(A9):桌面壳存在且无本地 authjs 会话(clientSession 为空)=当前
141
- // 会话是 remote-auth 回落的轻量云身份,登出走反向转移编排(数据翻回 guest)。
142
- // 本地凭据登录(clientSession 非空)与 web 照旧走 signOut 链——云身份回落仅在
143
- // authjs 会话无效时生效,clientSession 为空是云态判别式。
115
+ // 桌面云态(A9):桌面壳存在且会话来自云端回落=当前身份是 remote-auth
116
+ // 的轻量云身份,登出走反向转移编排(数据翻回 guest)。
117
+ // 判据演进(桌面 guest 会话修复):/api/auth/session 现会回落桌面身份并在
118
+ // 响应里带 desktopSession 标记,clientSession 不再为空——以标记为准;旧后端
119
+ // (无标记)保留「clientSession 为空」兜底。本地 authjs 凭据登录(无标记、
120
+ // clientSession 非空)与 web 照旧走 signOut 链。
144
121
  const bridge = getDesktopBridge();
145
- if (bridge && !clientSession && user?.id) {
122
+ if (bridge && (!clientSession || clientSession.desktopSession === 'cloud') && user?.id) {
146
123
  await desktopCloudLogout(bridge, user.id, queryClient);
147
124
  return;
148
125
  }
@@ -36,6 +36,10 @@ export interface Session {
36
36
  timezone?: string | null
37
37
  }
38
38
  expires: string
39
+ /** 桌面端会话来源标记(仅 /api/auth/session 的桌面回落响应携带):
40
+ * guest = 本地影子用户,cloud = remote-auth 云身份。web 部署与本地
41
+ * authjs 凭据登录无此字段——云态登出编排(user-menu)据此区分二者。 */
42
+ desktopSession?: 'guest' | 'cloud'
39
43
  }
40
44
 
41
45
  export function useSession() {
@@ -0,0 +1,239 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 桌面云态登出编排单测(A9 反向转移):钉死调用顺序与失败路径语义。
6
+ // 编排是纯依赖注入函数(无 React / i18n / DOM),此处全部替身驱动,测的是
7
+ // 「顺序、短路、best-effort 不阻塞」三件事,而不是各依赖自身实现。
8
+ import { describe, expect, it, vi } from 'vitest'
9
+ import { runDesktopLogout, type DesktopLogoutDeps } from '../desktop-logout'
10
+
11
+ type Log = string[]
12
+
13
+ /** 记录调用顺序的替身工厂(同一 Log 数组串起全部依赖) */
14
+ function makeDeps(
15
+ log: Log,
16
+ overrides: Partial<DesktopLogoutDeps> = {},
17
+ ): DesktopLogoutDeps {
18
+ return {
19
+ lock: async () => {
20
+ log.push('lock')
21
+ return { ok: true }
22
+ },
23
+ transfer: async (userId) => {
24
+ log.push(`transfer:${userId}`)
25
+ },
26
+ logoutRemote: async () => {
27
+ log.push('logoutRemote')
28
+ },
29
+ clearCloudSession: async () => {
30
+ log.push('clearCloudSession')
31
+ },
32
+ unlock: async () => {
33
+ log.push('unlock')
34
+ },
35
+ clearQueries: () => {
36
+ log.push('clearQueries')
37
+ },
38
+ reload: () => {
39
+ log.push('reload')
40
+ },
41
+ ...overrides,
42
+ }
43
+ }
44
+
45
+ describe('runDesktopLogout —— 成功路径', () => {
46
+ it('调用顺序严格:lock → transfer → logoutRemote → clearCloudSession → unlock → clearQueries → reload', async () => {
47
+ const log: Log = []
48
+ const status = await runDesktopLogout('cloud-user-1', makeDeps(log))
49
+
50
+ expect(status).toBe('done')
51
+ expect(log).toEqual([
52
+ 'lock',
53
+ 'transfer:cloud-user-1',
54
+ 'logoutRemote',
55
+ 'clearCloudSession',
56
+ 'unlock',
57
+ 'clearQueries',
58
+ 'reload',
59
+ ])
60
+ })
61
+
62
+ it('notify 先发 in-progress,成功路径不发 failed', async () => {
63
+ const notices: string[] = []
64
+ const status = await runDesktopLogout(
65
+ 'u',
66
+ makeDeps([], { notify: (n) => notices.push(n) }),
67
+ )
68
+
69
+ expect(status).toBe('done')
70
+ expect(notices).toEqual(['in-progress'])
71
+ })
72
+
73
+ it('旧壳(无 transferLock / clearCloudSession IPC):缺省即视为成功,仍走完并 reload', async () => {
74
+ const log: Log = []
75
+ const status = await runDesktopLogout(
76
+ 'u',
77
+ makeDeps(log, {
78
+ lock: undefined,
79
+ unlock: undefined,
80
+ clearCloudSession: undefined,
81
+ }),
82
+ )
83
+
84
+ expect(status).toBe('done')
85
+ expect(log).toEqual(['transfer:u', 'logoutRemote', 'clearQueries', 'reload'])
86
+ })
87
+ })
88
+
89
+ describe('runDesktopLogout —— 失败路径(留在云态可重试)', () => {
90
+ it('置锁返回 ok=false → 不转移、不 reload,通知 failed', async () => {
91
+ const log: Log = []
92
+ const notices: string[] = []
93
+ const status = await runDesktopLogout(
94
+ 'u',
95
+ makeDeps(log, {
96
+ lock: async () => ({ ok: false, error: 'locked' }),
97
+ notify: (n) => notices.push(n),
98
+ }),
99
+ )
100
+
101
+ expect(status).toBe('lock-failed')
102
+ expect(log).toEqual([])
103
+ expect(notices).toEqual(['in-progress', 'failed'])
104
+ })
105
+
106
+ it('置锁抛错 → 与 ok=false 同判(不转移、不 reload)', async () => {
107
+ const log: Log = []
108
+ const status = await runDesktopLogout(
109
+ 'u',
110
+ makeDeps(log, {
111
+ lock: async () => {
112
+ throw new Error('ipc down')
113
+ },
114
+ }),
115
+ )
116
+
117
+ expect(status).toBe('lock-failed')
118
+ expect(log).toEqual([])
119
+ })
120
+
121
+ it('转移失败 → 尽力解锁 + 通知 failed + 不 reload、不清缓存、不清双会话(保留云态可重试)', async () => {
122
+ const log: Log = []
123
+ const notices: string[] = []
124
+ const status = await runDesktopLogout(
125
+ 'u',
126
+ makeDeps(log, {
127
+ transfer: async () => {
128
+ log.push('transfer')
129
+ throw new Error('transfer failed')
130
+ },
131
+ notify: (n) => notices.push(n),
132
+ }),
133
+ )
134
+
135
+ expect(status).toBe('transfer-failed')
136
+ expect(log).toEqual(['lock', 'transfer', 'unlock'])
137
+ expect(notices).toEqual(['in-progress', 'failed'])
138
+ })
139
+
140
+ it('转移失败且解锁也抛错 → 不误报状态、不 reload', async () => {
141
+ const log: Log = []
142
+ const status = await runDesktopLogout(
143
+ 'u',
144
+ makeDeps(log, {
145
+ transfer: async () => {
146
+ throw new Error('transfer failed')
147
+ },
148
+ unlock: async () => {
149
+ log.push('unlock')
150
+ throw new Error('unlock failed')
151
+ },
152
+ }),
153
+ )
154
+
155
+ expect(status).toBe('transfer-failed')
156
+ expect(log).toEqual(['lock', 'unlock'])
157
+ })
158
+ })
159
+
160
+ describe('runDesktopLogout —— best-effort 不阻塞(既有实现语义)', () => {
161
+ it('解锁抛错 → 仍有 clearQueries + reload(reload 后引擎按 guest 守卫重判)', async () => {
162
+ const log: Log = []
163
+ const status = await runDesktopLogout(
164
+ 'u',
165
+ makeDeps(log, {
166
+ unlock: async () => {
167
+ log.push('unlock')
168
+ throw new Error('unlock failed')
169
+ },
170
+ }),
171
+ )
172
+
173
+ expect(status).toBe('done')
174
+ expect(log).toEqual([
175
+ 'lock',
176
+ 'transfer:u',
177
+ 'logoutRemote',
178
+ 'clearCloudSession',
179
+ 'unlock',
180
+ 'clearQueries',
181
+ 'reload',
182
+ ])
183
+ })
184
+
185
+ it('清云会话失败 → 不阻塞解锁 / 清缓存 / reload', async () => {
186
+ const log: Log = []
187
+ const status = await runDesktopLogout(
188
+ 'u',
189
+ makeDeps(log, {
190
+ clearCloudSession: async () => {
191
+ log.push('clearCloudSession')
192
+ throw new Error('clear failed')
193
+ },
194
+ }),
195
+ )
196
+
197
+ expect(status).toBe('done')
198
+ expect(log.slice(-4)).toEqual(['clearCloudSession', 'unlock', 'clearQueries', 'reload'])
199
+ expect(log).toContain('clearQueries')
200
+ })
201
+
202
+ it('清 remote-auth 失败 → 不阻塞后续(云会话清理由 serverFn 侧完成)', async () => {
203
+ const log: Log = []
204
+ const status = await runDesktopLogout(
205
+ 'u',
206
+ makeDeps(log, {
207
+ logoutRemote: async () => {
208
+ log.push('logoutRemote')
209
+ throw new Error('logout remote failed')
210
+ },
211
+ }),
212
+ )
213
+
214
+ expect(status).toBe('done')
215
+ expect(log.slice(-3)).toEqual(['unlock', 'clearQueries', 'reload'])
216
+ })
217
+ })
218
+
219
+ describe('runDesktopLogout —— 依赖调用次数', () => {
220
+ it('成功路径每个依赖恰好一次;notify 只在最前发一次 in-progress', async () => {
221
+ const spy = {
222
+ lock: vi.fn(async () => ({ ok: true })),
223
+ transfer: vi.fn(async () => undefined),
224
+ logoutRemote: vi.fn(async () => undefined),
225
+ clearCloudSession: vi.fn(async () => ({ ok: true })),
226
+ unlock: vi.fn(async () => undefined),
227
+ clearQueries: vi.fn(),
228
+ reload: vi.fn(),
229
+ notify: vi.fn(),
230
+ }
231
+ await runDesktopLogout('cloud', spy)
232
+
233
+ for (const fn of [spy.lock, spy.transfer, spy.logoutRemote, spy.clearCloudSession, spy.unlock, spy.clearQueries, spy.reload]) {
234
+ expect(fn).toHaveBeenCalledTimes(1)
235
+ }
236
+ expect(spy.notify).toHaveBeenCalledTimes(1)
237
+ expect(spy.notify).toHaveBeenCalledWith('in-progress')
238
+ })
239
+ })
@@ -8,7 +8,7 @@
8
8
  // 不触真实 JWT 解密、不真读写 DESKTOP_USER_DATA_DIR 下的 remote-auth.json
9
9
  // (#578 fs 实现勿真写盘)。desktop 判定走 DEPLOY_TARGET env stub
10
10
  // (isDesktopServer 调用时读 env,见 desktop-guest.ts:40)。
11
- import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12
12
  import { getCurrentSession } from '../session'
13
13
  import type { Session } from '../session'
14
14
 
@@ -45,6 +45,11 @@ const REMOTE_AUTH = {
45
45
  const REQ = () => new Request('http://localhost/api/auth/session')
46
46
 
47
47
  describe('getCurrentSession 桌面回落(A10 优先级链)', () => {
48
+ beforeEach(() => {
49
+ // 真实桌面后端由 Electron 主进程注入该 env(fail-open 自检的正常路径)
50
+ vi.stubEnv('DESKTOP_USER_DATA_DIR', '/tmp/allblue-desktop-test')
51
+ })
52
+
48
53
  afterEach(() => {
49
54
  vi.unstubAllEnvs()
50
55
  vi.clearAllMocks()
@@ -72,10 +77,45 @@ describe('getCurrentSession 桌面回落(A10 优先级链)', () => {
72
77
  expect(session?.expires).toBe('never')
73
78
  })
74
79
 
75
- it('①c authjs 无效 + desktop + remoteAuth 无 → null', async () => {
80
+ it('①c authjs 无效 + desktop + remoteAuth 无 → guest 本地影子身份(桌面免登录)', async () => {
76
81
  vi.stubEnv('DEPLOY_TARGET', 'desktop')
77
82
  getSessionMock.mockResolvedValue(null)
78
83
  readRemoteAuthMock.mockResolvedValue(null)
84
+ const session = await getCurrentSession(REQ())
85
+ // 桌面端不存在「未登录」态:guest 是本地合法身份,必须在本鉴权单点补齐
86
+ // (否则 account-team / design-detail 等软鉴权端点把 guest 判成未登录 →
87
+ // 路由 beforeLoad 收 unauthorized → 自动跳 /login,实测 P0)
88
+ expect(session?.user?.id).toBe('local-guest')
89
+ expect(session?.user?.isGuest).toBe(true)
90
+ expect(session?.expires).toBe('never')
91
+ })
92
+
93
+ it('①d authjs 读取出错 + desktop + remoteAuth 有 → 先回落云身份(不静默降级 guest)', async () => {
94
+ vi.stubEnv('DEPLOY_TARGET', 'desktop')
95
+ getSessionMock.mockRejectedValue(new Error('JWTSessionError'))
96
+ readRemoteAuthMock.mockResolvedValue(REMOTE_AUTH)
97
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
98
+ const session = await getCurrentSession(REQ())
99
+ // cloud 态遇坏 cookie / AUTH_SECRET 轮换:必须保住云身份归属——若直接回 guest,
100
+ // 界面看起来「数据全空」,期间写入还会落到 local-guest 属主(错主不可逆)
101
+ expect(session?.user?.id).toBe('cloud-user-abc')
102
+ expect(session?.user?.isGuest).toBe(false)
103
+ expect(readRemoteAuthMock).toHaveBeenCalled()
104
+ })
105
+
106
+ it('①e authjs 读取出错 + desktop + remoteAuth 无 → 回落 guest,本地不掉线', async () => {
107
+ vi.stubEnv('DEPLOY_TARGET', 'desktop')
108
+ getSessionMock.mockRejectedValue(new Error('JWTSessionError'))
109
+ readRemoteAuthMock.mockResolvedValue(null)
110
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
111
+ const session = await getCurrentSession(REQ())
112
+ expect(session?.user?.id).toBe('local-guest')
113
+ })
114
+
115
+ it('①f authjs 读取出错 + 非 desktop → null(web 未登录语义不变)', async () => {
116
+ vi.stubEnv('DEPLOY_TARGET', '')
117
+ getSessionMock.mockRejectedValue(new Error('JWTSessionError'))
118
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
79
119
  expect(await getCurrentSession(REQ())).toBeNull()
80
120
  })
81
121
 
@@ -93,3 +133,46 @@ describe('getCurrentSession 桌面回落(A10 优先级链)', () => {
93
133
  expect(readRemoteAuthMock).not.toHaveBeenCalled()
94
134
  })
95
135
  })
136
+
137
+ describe('桌面 guest 回落的安全自检(fail-open 告警,进程级一次)', () => {
138
+ // 模块级 warn-once 标记随实例存在:每个用例用 resetModules + 动态 import
139
+ // 拿全新实例,否则第二条会被前一条的标记吞掉
140
+ async function importFreshSession(env: { desktopUserDataDir?: string }) {
141
+ vi.resetModules()
142
+ vi.stubEnv('DEPLOY_TARGET', 'desktop')
143
+ vi.stubEnv('DESKTOP_USER_DATA_DIR', env.desktopUserDataDir ?? '')
144
+ vi.doMock('start-authjs', () => ({ getSession: async () => null }))
145
+ vi.doMock('@/lib/auth/auth.config', () => ({ getAuthConfig: async () => ({}) }))
146
+ vi.doMock('@/server/remote-auth', () => ({ readRemoteAuth: async () => null }))
147
+ return import('../session')
148
+ }
149
+
150
+ afterEach(() => {
151
+ vi.unstubAllEnvs()
152
+ vi.restoreAllMocks()
153
+ })
154
+
155
+ it('DEPLOY_TARGET=desktop 且无 DESKTOP_USER_DATA_DIR → console.error 一次(cloud 部署误设信号)', async () => {
156
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
157
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
158
+
159
+ const { getCurrentSession: fresh } = await importFreshSession({})
160
+ expect((await fresh(REQ()))?.user?.id).toBe('local-guest')
161
+ expect((await fresh(REQ()))?.user?.id).toBe('local-guest')
162
+
163
+ const securityAlerts = errorSpy.mock.calls.filter((args) => String(args[0]).includes('DESKTOP_USER_DATA_DIR'))
164
+ expect(securityAlerts).toHaveLength(1)
165
+ expect(String(securityAlerts[0]?.[0])).toContain('安全边界')
166
+ })
167
+
168
+ it('已注入 DESKTOP_USER_DATA_DIR(正常桌面壳)→ 不告警', async () => {
169
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
170
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
171
+
172
+ const { getCurrentSession: fresh } = await importFreshSession({ desktopUserDataDir: '/tmp/allblue-desktop' })
173
+ expect((await fresh(REQ()))?.user?.id).toBe('local-guest')
174
+
175
+ const securityAlerts = errorSpy.mock.calls.filter((args) => String(args[0]).includes('DESKTOP_USER_DATA_DIR'))
176
+ expect(securityAlerts).toHaveLength(0)
177
+ })
178
+ })