@autional/shared 0.0.0-stage → 0.1.0-rc.33

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 (115) hide show
  1. package/LICENSE +661 -0
  2. package/package.json +49 -3
  3. package/src/api/__tests__/contract.test.ts +172 -0
  4. package/src/api/__tests__/entry-plane.test.ts +119 -0
  5. package/src/api/__tests__/namespace.test.ts +27 -0
  6. package/src/api/__tests__/preflight-refresh.test.ts +89 -0
  7. package/src/api/bff-client.ts +94 -0
  8. package/src/api/client.ts +191 -0
  9. package/src/api/entry-plane.ts +61 -0
  10. package/src/api/namespace.ts +14 -0
  11. package/src/api/trace.ts +21 -0
  12. package/src/auth/__tests__/ac007-role-source.test.ts +124 -0
  13. package/src/auth/__tests__/auth-machine.test.ts +157 -0
  14. package/src/auth/__tests__/auth-trace.test.ts +321 -0
  15. package/src/auth/__tests__/build-login-url.test.ts +259 -0
  16. package/src/auth/__tests__/get-current-role.test.ts +61 -0
  17. package/src/auth/__tests__/jwt-payload.test.ts +49 -0
  18. package/src/auth/__tests__/logout-redirect.test.ts +44 -0
  19. package/src/auth/__tests__/logout-revoke.test.ts +219 -0
  20. package/src/auth/__tests__/oauth-callback-error-codes.test.ts +69 -0
  21. package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
  22. package/src/auth/__tests__/oauth-initiate-stay.test.ts +118 -0
  23. package/src/auth/__tests__/oauth-login-user-resolution.test.ts +185 -0
  24. package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
  25. package/src/auth/__tests__/session-client-id.test.ts +147 -0
  26. package/src/auth/__tests__/session-terminal-redirect.test.ts +138 -0
  27. package/src/auth/__tests__/slug-from-url.test.ts +112 -0
  28. package/src/auth/auth-machine.ts +151 -0
  29. package/src/auth/auth-queries.ts +118 -0
  30. package/src/auth/auth-trace.ts +511 -0
  31. package/src/auth/build-tenant-urls.ts +48 -0
  32. package/src/auth/jwt-payload.ts +21 -0
  33. package/src/auth/oauth-client-from-slug.ts +119 -0
  34. package/src/auth/oauth-client-id-store.ts +41 -0
  35. package/src/auth/oauth-login.ts +388 -0
  36. package/src/auth/pkce.ts +141 -0
  37. package/src/auth/portal-config.ts +68 -0
  38. package/src/auth/roles.ts +120 -0
  39. package/src/auth/service.ts +433 -0
  40. package/src/auth/slug-from-url.ts +92 -0
  41. package/src/auth/store.ts +227 -0
  42. package/src/auth/tenant-route-middleware.ts +113 -0
  43. package/src/auth/tenant-slug-context.tsx +9 -0
  44. package/src/branding/BrandingInitializer.tsx +60 -0
  45. package/src/branding/__tests__/brand-vars.test.ts +126 -0
  46. package/src/branding/brand-color.ts +193 -0
  47. package/src/branding/branding-cache.ts +71 -0
  48. package/src/branding/extract-branding.ts +27 -0
  49. package/src/branding/index.ts +15 -0
  50. package/src/branding/tenant-store.ts +13 -0
  51. package/src/branding/types.ts +14 -0
  52. package/src/branding/useBranding.ts +101 -0
  53. package/src/components/AuditStatsOnly.tsx +65 -0
  54. package/src/components/OAuthCallbackPage.tsx +83 -0
  55. package/src/components/PortalGuard.tsx +105 -0
  56. package/src/components/RequireAuth.tsx +220 -0
  57. package/src/components/TenantIndexGuard.tsx +131 -0
  58. package/src/components/TenantRootRedirect.tsx +49 -0
  59. package/src/components/__tests__/RequireAuth.adversarial.test.tsx +165 -0
  60. package/src/components/__tests__/RequireAuth.stay.test.tsx +131 -0
  61. package/src/components/__tests__/RequireAuth.test.tsx +296 -0
  62. package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
  63. package/src/components/__tests__/TenantRootRedirect.test.tsx +180 -0
  64. package/src/config/__tests__/portal-resolver.test.ts +302 -0
  65. package/src/config/__tests__/valid-redirect.test.ts +47 -0
  66. package/src/config/constants.ts +27 -0
  67. package/src/config/index.ts +426 -0
  68. package/src/config/resolve-basename.ts +57 -0
  69. package/src/constants/api-paths.ts +450 -0
  70. package/src/generated/api.ts +13012 -0
  71. package/src/generated/queries.ts +12984 -0
  72. package/src/generated/types.ts +13982 -0
  73. package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
  74. package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
  75. package/src/hooks/__tests__/useCurrentTenantIdOr.test.tsx +38 -0
  76. package/src/hooks/__tests__/useLogout.test.tsx +61 -0
  77. package/src/hooks/__tests__/usePermission.map.test.tsx +156 -0
  78. package/src/hooks/__tests__/usePortalCatalog.test.tsx +301 -0
  79. package/src/hooks/__tests__/useSodMode.test.tsx +205 -0
  80. package/src/hooks/useAuth.ts +239 -0
  81. package/src/hooks/useBootstrap.ts +74 -0
  82. package/src/hooks/useCurrentRole.ts +18 -0
  83. package/src/hooks/useCurrentTenantIdOr.ts +29 -0
  84. package/src/hooks/useLogout.ts +42 -0
  85. package/src/hooks/usePermission.ts +128 -0
  86. package/src/hooks/usePortalCatalog.ts +199 -0
  87. package/src/hooks/useSodMode.ts +81 -0
  88. package/src/index.ts +247 -0
  89. package/src/lib/__tests__/push.test.ts +30 -0
  90. package/src/lib/push.ts +44 -0
  91. package/src/seo/dom-utils.ts +94 -0
  92. package/src/seo/index.ts +6 -0
  93. package/src/seo/jsonld.ts +29 -0
  94. package/src/seo/types.ts +29 -0
  95. package/src/seo/usePageMeta.ts +17 -0
  96. package/src/seo/usePageTitle.ts +14 -0
  97. package/src/seo/useSEO.ts +80 -0
  98. package/src/types/index.ts +190 -0
  99. package/src/utils/__tests__/case.test.ts +94 -0
  100. package/src/utils/__tests__/error.test.ts +72 -0
  101. package/src/utils/__tests__/mock-test.ts +15 -0
  102. package/src/utils/__tests__/page.test.ts +54 -0
  103. package/src/utils/__tests__/query-state.test.ts +120 -0
  104. package/src/utils/browser.ts +15 -0
  105. package/src/utils/case.ts +59 -0
  106. package/src/utils/error.ts +35 -0
  107. package/src/utils/format.ts +63 -0
  108. package/src/utils/page.ts +57 -0
  109. package/src/utils/password-transmission.test.ts +145 -0
  110. package/src/utils/password-transmission.ts +253 -0
  111. package/src/utils/query-state.ts +85 -0
  112. package/src/utils/response.ts +71 -0
  113. package/src/utils/vite.ts +4 -0
  114. package/src/vite-env.d.ts +19 -0
  115. package/README.md +0 -3
@@ -0,0 +1,301 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4
+ import { renderHook, waitFor, cleanup } from '@testing-library/react';
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6
+ import { usePortalCatalog, PLATFORM_TENANT_ID } from '../usePortalCatalog';
7
+ import { useAuthStore } from '../../auth/store';
8
+ import { apiClient } from '../../api/client';
9
+
10
+ /** 每测试独立 QueryClient(同一 wrapper 引用,避免缓存跨用例串扰) */
11
+ function makeWrapper() {
12
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
13
+ return function Wrapper({ children }: { children: React.ReactNode }) {
14
+ return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
15
+ };
16
+ }
17
+
18
+ function resetAuthStore(): void {
19
+ useAuthStore.setState({
20
+ user: null,
21
+ accessToken: null,
22
+ refreshToken: null,
23
+ tenants: [],
24
+ currentTenantId: null,
25
+ permissions: [],
26
+ isAuthenticated: false,
27
+ });
28
+ window.localStorage.clear();
29
+ }
30
+
31
+ /**
32
+ * 桩 apiClient.get(axios 层)。
33
+ * 解析值形状 = 响应拦截器处理后的产物(unwrapped + camelCase):
34
+ * - {code,items} → {items,...};{code,data} → data 本体
35
+ */
36
+ let getMock: ReturnType<typeof vi.fn>;
37
+
38
+ const GET_URL = (call: number) => String(getMock.mock.calls[call]![0]);
39
+ const GET_CONFIG = (call: number) => getMock.mock.calls[call]![1] as { params: Record<string, string> };
40
+
41
+ describe('usePortalCatalog', () => {
42
+ beforeEach(() => {
43
+ resetAuthStore();
44
+ useAuthStore.setState({ accessToken: 'test-token' });
45
+ getMock = vi.fn();
46
+ vi.spyOn(apiClient, 'get').mockImplementation(getMock as unknown as typeof apiClient.get);
47
+ });
48
+
49
+ afterEach(() => {
50
+ cleanup();
51
+ resetAuthStore();
52
+ vi.restoreAllMocks();
53
+ });
54
+
55
+ it('self 端点:items 形状解析、默认排除 auth/landing/authenticator、order 升序、URL 按 slug 拼装', async () => {
56
+ getMock.mockResolvedValue({
57
+ data: {
58
+ items: [
59
+ { code: 'auth', name: 'Sign-in', order: 1 },
60
+ { code: 'user', name: '用户门户', order: 3 },
61
+ { code: 'admin', name: '管理控制台', order: 1 },
62
+ { code: 'landing', name: 'Home', order: 9 },
63
+ { code: 'authenticator', name: '验证器', order: 2 },
64
+ ],
65
+ },
66
+ });
67
+ const { result } = renderHook(
68
+ () => usePortalCatalog({ tenantId: 't1', slug: 'acme-corp', role: 'admin' }),
69
+ { wrapper: makeWrapper() },
70
+ );
71
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
72
+
73
+ expect(getMock).toHaveBeenCalledTimes(1);
74
+ expect(GET_URL(0)).toBe('/tenant/api/v1/tenants/t1/applications');
75
+ expect(GET_CONFIG(0).params).toEqual({
76
+ type: 'portal',
77
+ is_platform: 'true',
78
+ status: 'active',
79
+ });
80
+
81
+ // allPortals:可见集合、保持服务端顺序
82
+ expect(result.current.allPortals.map((p) => p.code)).toEqual(['user', 'admin']);
83
+ // portals:order 升序(admin=1, user=3)
84
+ expect(result.current.portals.map((p) => p.code)).toEqual(['admin', 'user']);
85
+ const admin = result.current.portals.find((p) => p.code === 'admin')!;
86
+ expect(admin.url).toBe('http://admin.localhost/acme-corp');
87
+ expect(result.current.isError).toBe(false);
88
+ });
89
+
90
+ it('data[] 数组形状容错(部分路径直出数组)', async () => {
91
+ getMock.mockResolvedValue({ data: [{ code: 'user', name: '用户门户', order: 2 }] });
92
+ const { result } = renderHook(() => usePortalCatalog({ tenantId: 't1' }), {
93
+ wrapper: makeWrapper(),
94
+ });
95
+ await waitFor(() => expect(result.current.portals).toHaveLength(1));
96
+ expect(result.current.portals[0]!.code).toBe('user');
97
+ });
98
+
99
+ it('业务失败(code!==0)→ isError 且不产出条目', async () => {
100
+ getMock.mockResolvedValue({ data: { code: 40000503, message: 'plane forbidden' } });
101
+ const { result } = renderHook(() => usePortalCatalog({ tenantId: 't1' }), {
102
+ wrapper: makeWrapper(),
103
+ });
104
+ await waitFor(() => expect(result.current.isError).toBe(true));
105
+ expect(result.current.portals).toEqual([]);
106
+ expect(result.current.allPortals).toEqual([]);
107
+ });
108
+
109
+ it('请求异常(axios reject,如 403/网络失败)→ isError(平面错配可降级)', async () => {
110
+ getMock.mockRejectedValue(new Error('Request failed with status code 403'));
111
+ const { result } = renderHook(() => usePortalCatalog({ tenantId: 't1' }), {
112
+ wrapper: makeWrapper(),
113
+ });
114
+ await waitFor(() => expect(result.current.isError).toBe(true));
115
+ });
116
+
117
+ it('tenantId / token / enabled 任一缺省 → 不发请求', async () => {
118
+ renderHook(() => usePortalCatalog({ tenantId: null }), { wrapper: makeWrapper() });
119
+ expect(getMock).not.toHaveBeenCalled();
120
+
121
+ resetAuthStore();
122
+ renderHook(() => usePortalCatalog({ tenantId: 't1' }), { wrapper: makeWrapper() });
123
+ expect(getMock).not.toHaveBeenCalled();
124
+
125
+ useAuthStore.setState({ accessToken: 'test-token' });
126
+ renderHook(() => usePortalCatalog({ tenantId: 't1', enabled: false }), {
127
+ wrapper: makeWrapper(),
128
+ });
129
+ expect(getMock).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it('audience=admin → 管理面双入口路径(admin/security/platform 控制台)', async () => {
133
+ getMock.mockResolvedValue({ data: { items: [] } });
134
+ renderHook(() => usePortalCatalog({ tenantId: 't1', audience: 'admin' }), {
135
+ wrapper: makeWrapper(),
136
+ });
137
+ await waitFor(() => expect(getMock).toHaveBeenCalledTimes(1));
138
+ expect(GET_URL(0)).toBe('/tenant/api/v1/admin/tenants/t1/applications');
139
+ });
140
+
141
+ it('exclude 自定义:仅过滤指定 code(不叠加默认排除)', async () => {
142
+ getMock.mockResolvedValue({
143
+ data: {
144
+ items: [
145
+ { code: 'admin', name: 'a', order: 1 },
146
+ { code: 'auth', name: 'b', order: 2 },
147
+ ],
148
+ },
149
+ });
150
+ const { result } = renderHook(() => usePortalCatalog({ tenantId: 't1', exclude: ['admin'] }), {
151
+ wrapper: makeWrapper(),
152
+ });
153
+ await waitFor(() => expect(result.current.allPortals).toHaveLength(1));
154
+ expect(result.current.allPortals[0]!.code).toBe('auth');
155
+ });
156
+
157
+ it('非 slug 白名单门户(status)不拼租户段', async () => {
158
+ getMock.mockResolvedValue({
159
+ data: { items: [{ code: 'status', name: '状态页', order: 1 }] },
160
+ });
161
+ const { result } = renderHook(
162
+ () => usePortalCatalog({ tenantId: 't1', slug: 'acme-corp' }),
163
+ { wrapper: makeWrapper() },
164
+ );
165
+ await waitFor(() => expect(result.current.portals).toHaveLength(1));
166
+ expect(result.current.portals[0]!.url).toBe('http://status.localhost');
167
+ });
168
+
169
+ it('可见性(入口门禁镜像):member 角色不可见 admin/security/platform,user/status 可见', async () => {
170
+ getMock.mockResolvedValue({
171
+ data: {
172
+ items: [
173
+ { code: 'admin', name: '管理控制台', order: 1 },
174
+ { code: 'security', name: '安全控制台', order: 2 },
175
+ { code: 'platform', name: '平台控制台', order: 3 },
176
+ { code: 'user', name: '用户门户', order: 4 },
177
+ { code: 'status', name: '状态页', order: 5 },
178
+ ],
179
+ },
180
+ });
181
+ const { result } = renderHook(() => usePortalCatalog({ tenantId: 't1', role: 'member' }), {
182
+ wrapper: makeWrapper(),
183
+ });
184
+ await waitFor(() => expect(result.current.portals).toHaveLength(2));
185
+ expect(result.current.portals.map((p) => p.code)).toEqual(['user', 'status']);
186
+ // allPortals 与 portals 同一可见集合(偏好面板不再泄露不可进门户)
187
+ expect(result.current.allPortals.map((p) => p.code)).toEqual(['user', 'status']);
188
+ });
189
+
190
+ it('可见性:管理面角色(非平台租户)可管理面门户但不可见 platform', async () => {
191
+ getMock.mockResolvedValue({
192
+ data: {
193
+ items: [
194
+ { code: 'admin', name: '管理控制台', order: 1 },
195
+ { code: 'security', name: '安全控制台', order: 2 },
196
+ { code: 'platform', name: '平台控制台', order: 3 },
197
+ { code: 'user', name: '用户门户', order: 4 },
198
+ ],
199
+ },
200
+ });
201
+ const { result } = renderHook(
202
+ () => usePortalCatalog({ tenantId: 't1', role: 'security_admin' }),
203
+ { wrapper: makeWrapper() },
204
+ );
205
+ await waitFor(() => expect(result.current.portals).toHaveLength(3));
206
+ expect(result.current.portals.map((p) => p.code)).toEqual(['admin', 'security', 'user']);
207
+ });
208
+
209
+ it('可见性:平台租户成员可见 platform(store tenants 含平台租户 ID)', async () => {
210
+ useAuthStore.setState({
211
+ tenants: [{ id: PLATFORM_TENANT_ID, name: 'platform', role: 'super_admin' }],
212
+ currentTenantId: PLATFORM_TENANT_ID,
213
+ });
214
+ getMock.mockResolvedValue({
215
+ data: {
216
+ items: [
217
+ { code: 'platform', name: '平台控制台', order: 1 },
218
+ { code: 'admin', name: '管理控制台', order: 2 },
219
+ { code: 'user', name: '用户门户', order: 3 },
220
+ ],
221
+ },
222
+ });
223
+ const { result } = renderHook(() => usePortalCatalog({ tenantId: PLATFORM_TENANT_ID }), {
224
+ wrapper: makeWrapper(),
225
+ });
226
+ await waitFor(() => expect(result.current.portals).toHaveLength(3));
227
+ expect(result.current.portals.map((p) => p.code)).toEqual(['platform', 'admin', 'user']);
228
+ });
229
+
230
+ it('可见性:role 显式 null → 管理面门户不可见(按“无角色”过滤)', async () => {
231
+ getMock.mockResolvedValue({
232
+ data: {
233
+ items: [
234
+ { code: 'admin', name: '管理控制台', order: 1 },
235
+ { code: 'user', name: '用户门户', order: 2 },
236
+ ],
237
+ },
238
+ });
239
+ const { result } = renderHook(() => usePortalCatalog({ tenantId: 't1', role: null }), {
240
+ wrapper: makeWrapper(),
241
+ });
242
+ await waitFor(() => expect(result.current.portals).toHaveLength(1));
243
+ expect(result.current.portals[0]!.code).toBe('user');
244
+ });
245
+
246
+ it('allowed_roles 叠加:存在且不含当前角色 → 隐藏(camelCase 键,apiClient 归一后形态)', async () => {
247
+ getMock.mockResolvedValue({
248
+ data: {
249
+ items: [
250
+ { code: 'status', name: '状态页', order: 1, config: { portal: { allowedRoles: ['admin'] } } },
251
+ { code: 'user', name: '用户门户', order: 2 },
252
+ ],
253
+ },
254
+ });
255
+ const { result } = renderHook(() => usePortalCatalog({ tenantId: 't1', role: 'member' }), {
256
+ wrapper: makeWrapper(),
257
+ });
258
+ await waitFor(() => expect(result.current.portals).toHaveLength(1));
259
+ expect(result.current.portals[0]!.code).toBe('user');
260
+ });
261
+
262
+ it('allowed_roles 叠加:snake 原始键兼容(后端透出前的直传形态)', async () => {
263
+ getMock.mockResolvedValue({
264
+ data: {
265
+ items: [
266
+ { code: 'status', name: '状态页', order: 1, config: { portal: { allowed_roles: ['admin'] } } },
267
+ { code: 'user', name: '用户门户', order: 2 },
268
+ ],
269
+ },
270
+ });
271
+ const { result } = renderHook(() => usePortalCatalog({ tenantId: 't1', role: 'member' }), {
272
+ wrapper: makeWrapper(),
273
+ });
274
+ await waitFor(() => expect(result.current.portals).toHaveLength(1));
275
+ expect(result.current.portals[0]!.code).toBe('user');
276
+ });
277
+
278
+ it('role 缺省回退 store 当前角色(useCurrentRole),平台租户成员判定不依赖当前租户', async () => {
279
+ useAuthStore.setState({
280
+ tenants: [
281
+ { id: 't1', name: 'demo', role: 'super_admin' },
282
+ { id: PLATFORM_TENANT_ID, name: 'platform', role: 'admin' },
283
+ ],
284
+ currentTenantId: 't1',
285
+ });
286
+ getMock.mockResolvedValue({
287
+ data: {
288
+ items: [
289
+ { code: 'admin', name: '管理控制台', order: 1 },
290
+ { code: 'platform', name: '平台控制台', order: 2 },
291
+ ],
292
+ },
293
+ });
294
+ const { result } = renderHook(() => usePortalCatalog({ tenantId: 't1' }), {
295
+ wrapper: makeWrapper(),
296
+ });
297
+ await waitFor(() => expect(result.current.portals).toHaveLength(2));
298
+ // 当前租户 t1 role=super_admin → admin 可见;另一成员租户为平台租户 → platform 可见
299
+ expect(result.current.portals.map((p) => p.code)).toEqual(['admin', 'platform']);
300
+ });
301
+ });
@@ -0,0 +1,205 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4
+ import { renderHook, cleanup, waitFor } from '@testing-library/react';
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6
+
7
+ // ============================================================
8
+ // U97 回归锁 — useSodMode 四连缺陷修复:
9
+ // ① URL 含 /tenant 服务段(不再是 /api/v1/... 裸径 → 网关 404)
10
+ // ② 请求参数为租户 ULID(不再是 slug → 后端按主键 id 查找必 404)
11
+ // ③ 失败不再静默吞成 'single'(返回 null;错误留在 react-query 通道)
12
+ // ④ 读取键为 camelCase `sodMode`(响应拦截器 camelCaseKeys 契约;
13
+ // 读 snake_case `sod_mode` 恒 undefined —— 请求虽 200 但值被吞)
14
+ // ============================================================
15
+
16
+ vi.mock('../../api/client', () => ({
17
+ apiClient: { get: vi.fn() },
18
+ }));
19
+
20
+ import { apiClient } from '../../api/client';
21
+ import { useAuthStore } from '../../auth/store';
22
+ import { useSodMode, useIsAuditRestricted } from '../useSodMode';
23
+
24
+ const mockedGet = vi.mocked(apiClient.get);
25
+
26
+ /** 租户 ULID(后端 GetSodConfig 按主键查找的键) */
27
+ const TENANT_ULID = '01M24BB8G083JHWKTXXVGWMWQA';
28
+ /** slug 字样:URL 中不得出现(缺陷② 的负例锚点) */
29
+ const SLUG = 'demo';
30
+ const TENANTS = [{ id: TENANT_ULID, name: SLUG, role: 'admin' }];
31
+
32
+ /** 模拟 axios 响应拦截器(api/client.ts camelCaseKeys)之后的形状 */
33
+ function sodResponse(sodMode: 'single' | 'strict') {
34
+ return {
35
+ data: {
36
+ tenantId: TENANT_ULID,
37
+ sodMode,
38
+ updatedAt: '2026-09-30T00:00:00Z',
39
+ },
40
+ } as never;
41
+ }
42
+
43
+ function createWrapper() {
44
+ const queryClient = new QueryClient({
45
+ defaultOptions: { queries: { retry: false, retryDelay: 10 } },
46
+ });
47
+ return {
48
+ queryClient,
49
+ wrapper: function Wrapper({ children }: { children: React.ReactNode }) {
50
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
51
+ },
52
+ };
53
+ }
54
+
55
+ /** 重置 store + localStorage,避免跨用例污染 */
56
+ function resetAuthStore(): void {
57
+ useAuthStore.setState({
58
+ user: null,
59
+ accessToken: null,
60
+ refreshToken: null,
61
+ tenants: [],
62
+ currentTenantId: null,
63
+ permissions: [],
64
+ isAuthenticated: false,
65
+ });
66
+ window.localStorage.clear();
67
+ }
68
+
69
+ describe('useSodMode(U97 回归锁:路径服务段 / ULID 参数 / 失败不吞 / camelCase 键)', () => {
70
+ beforeEach(() => {
71
+ vi.clearAllMocks();
72
+ resetAuthStore();
73
+ });
74
+
75
+ afterEach(() => {
76
+ cleanup();
77
+ resetAuthStore();
78
+ });
79
+
80
+ it('①+②+④ 成功: URL = /tenant/api/v1/admin/tenants/<ULID>/sod-config 且读到 sodMode=strict', async () => {
81
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: TENANT_ULID, accessToken: 't' });
82
+ mockedGet.mockResolvedValue(sodResponse('strict'));
83
+
84
+ const { wrapper } = createWrapper();
85
+ const { result } = renderHook(() => useSodMode(), { wrapper });
86
+
87
+ await waitFor(() => expect(result.current).toBe('strict'));
88
+
89
+ // ① 服务段:完整路径精确断言(旧实现 /api/v1/... 裸径将 FAIL)
90
+ expect(mockedGet).toHaveBeenCalledWith(
91
+ `/tenant/api/v1/admin/tenants/${TENANT_ULID}/sod-config`,
92
+ );
93
+ const calledUrl = mockedGet.mock.calls[0][0] as string;
94
+ expect(calledUrl).not.toMatch(/^\/api\/v1\//);
95
+ // ② 参数是 ULID,不是 slug
96
+ expect(calledUrl).toContain(TENANT_ULID);
97
+ expect(calledUrl).not.toContain(`/${SLUG}/`);
98
+ });
99
+
100
+ it('④ 服务端返回 single → single(正常值不丢)', async () => {
101
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: TENANT_ULID, accessToken: 't' });
102
+ mockedGet.mockResolvedValue(sodResponse('single'));
103
+
104
+ const { wrapper } = createWrapper();
105
+ const { result } = renderHook(() => useSodMode(), { wrapper });
106
+
107
+ await waitFor(() => expect(result.current).toBe('single'));
108
+ });
109
+
110
+ it('② 无租户(currentTenantId=null)→ 不发请求,返回 null', () => {
111
+ const { wrapper } = createWrapper();
112
+ const { result } = renderHook(() => useSodMode(), { wrapper });
113
+
114
+ expect(result.current).toBeNull();
115
+ expect(mockedGet).not.toHaveBeenCalled();
116
+ });
117
+
118
+ it('③ 请求失败 → null(不吞成 single),且错误进入 query error 态(不再静默)', async () => {
119
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: TENANT_ULID, accessToken: 't' });
120
+ mockedGet.mockRejectedValue(new Error('Network Error'));
121
+
122
+ const { queryClient, wrapper } = createWrapper();
123
+ const { result } = renderHook(() => useSodMode(), { wrapper });
124
+
125
+ // retry:1(hook 显式)→ 失败重试一次后进入 error 态
126
+ await waitFor(
127
+ () => {
128
+ expect(queryClient.getQueryState(['sod-config', TENANT_ULID])?.status).toBe('error');
129
+ },
130
+ { timeout: 5000 },
131
+ );
132
+ // 关键回归锁:绝不是 'single'(旧实现 catch 吞错 + `?? 'single'` 恒 single)
133
+ expect(result.current).toBeNull();
134
+ expect(result.current).not.toBe('single');
135
+ });
136
+ });
137
+
138
+ describe('useIsAuditRestricted(U97 消费面语义)', () => {
139
+ beforeEach(() => {
140
+ vi.clearAllMocks();
141
+ resetAuthStore();
142
+ });
143
+
144
+ afterEach(() => {
145
+ cleanup();
146
+ resetAuthStore();
147
+ });
148
+
149
+ it('strict ∧ role=admin → true(strict 模式 L2 限制生效)', async () => {
150
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: TENANT_ULID, accessToken: 't' });
151
+ mockedGet.mockResolvedValue(sodResponse('strict'));
152
+
153
+ const { wrapper } = createWrapper();
154
+ const { result } = renderHook(() => useIsAuditRestricted(), { wrapper });
155
+
156
+ await waitFor(() => expect(result.current).toBe(true));
157
+ });
158
+
159
+ it('strict ∧ 非 admin → false', async () => {
160
+ useAuthStore.setState({
161
+ tenants: [{ id: TENANT_ULID, name: SLUG, role: 'member' }],
162
+ currentTenantId: TENANT_ULID,
163
+ accessToken: 't',
164
+ });
165
+ mockedGet.mockResolvedValue(sodResponse('strict'));
166
+
167
+ const { queryClient, wrapper } = createWrapper();
168
+ const { result } = renderHook(() => useIsAuditRestricted(), { wrapper });
169
+
170
+ // 等 sod 数据确实到达(success)后再断言 —— 避免「数据未到即 false」的假绿
171
+ await waitFor(() => {
172
+ expect(queryClient.getQueryState(['sod-config', TENANT_ULID])?.status).toBe('success');
173
+ });
174
+ expect(result.current).toBe(false);
175
+ });
176
+
177
+ it('single(admin)→ false', async () => {
178
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: TENANT_ULID, accessToken: 't' });
179
+ mockedGet.mockResolvedValue(sodResponse('single'));
180
+
181
+ const { queryClient, wrapper } = createWrapper();
182
+ const { result } = renderHook(() => useIsAuditRestricted(), { wrapper });
183
+
184
+ await waitFor(() => {
185
+ expect(queryClient.getQueryState(['sod-config', TENANT_ULID])?.status).toBe('success');
186
+ });
187
+ expect(result.current).toBe(false);
188
+ });
189
+
190
+ it('读取失败(null 模式)→ false(不误判受限;fail-open 语义保持)', async () => {
191
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: TENANT_ULID, accessToken: 't' });
192
+ mockedGet.mockRejectedValue(new Error('Network Error'));
193
+
194
+ const { queryClient, wrapper } = createWrapper();
195
+ const { result } = renderHook(() => useIsAuditRestricted(), { wrapper });
196
+
197
+ await waitFor(
198
+ () => {
199
+ expect(queryClient.getQueryState(['sod-config', TENANT_ULID])?.status).toBe('error');
200
+ },
201
+ { timeout: 5000 },
202
+ );
203
+ expect(result.current).toBe(false);
204
+ });
205
+ });