@autional-cn/shared 0.1.0-rc.6 → 0.1.0-rc.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": "@autional-cn/shared",
3
- "version": "0.1.0-rc.6",
3
+ "version": "0.1.0-rc.7",
4
4
  "description": "Autional shared layer — API client, auth, types, hooks, utils",
5
5
  "license": "AGPL-3.0",
6
6
  "main": "./src/index.ts",
@@ -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
+ });
@@ -8,8 +8,9 @@
8
8
  * 'single' — 小客户模式(默认): admin 可看 L1 统计 + L2 操作反馈
9
9
  * 'strict' — 大客户/受监管: admin 仅看 L1 统计摘要
10
10
  *
11
- * API: GET /api/v1/admin/tenants/{tenantSlug}/sod-config
12
- * PUT /api/v1/admin/tenants/{tenantSlug}/sod-config
11
+ * API: GET /tenant/api/v1/admin/tenants/{tenantId}/sod-config (apiClient baseURL=/bff)
12
+ * PUT /tenant/api/v1/admin/tenants/{tenantId}/sod-config
13
+ * {tenantId} 为租户 ULID —— 后端 GetSodConfig 按租户主键(id)查找,传 slug 必 404。
13
14
  *
14
15
  * @see document/architecture/decisions/007-security-dashboard-audit.md §2.3
15
16
  */
@@ -19,49 +20,56 @@
19
20
  import { useMemo } from 'react';
20
21
  import { useQuery } from '@tanstack/react-query';
21
22
  import { apiClient } from '../api/client';
23
+ import { API as API_PATHS } from '../constants/api-paths';
22
24
  import { extractItem } from '../utils/response';
23
- import { useTenantSlug } from '../auth/tenant-slug-context';
25
+ import { useAuthStore } from '../auth/store';
24
26
  import { useCurrentRole } from './useCurrentRole';
25
27
 
26
28
  export type SodMode = 'single' | 'strict';
27
29
 
30
+ /**
31
+ * 响应字段为 camelCase —— axios 响应拦截器(api/client.ts)对后端
32
+ * `{tenant_id, sod_mode, updated_at}` 做过 camelCaseKeys 转换。
33
+ */
28
34
  interface SodConfigResponse {
29
- tenant_id: string;
30
- sod_mode: SodMode;
31
- updated_at: string;
35
+ tenantId: string;
36
+ sodMode: SodMode;
37
+ updatedAt: string;
32
38
  }
33
39
 
34
40
  /**
35
- * 返回当前用户的 sod_mode。
36
- * 从 tenant-service 的 /api/v1/admin/tenants/{slug}/sod-config 读取。
37
- * 失败或未配置时默认返回 'single'。
41
+ * 返回当前租户的 sod_mode。
42
+ * 从 tenant-service 的 /admin/tenants/{ULID}/sod-config 读取(经 /bff 网关)。
43
+ *
44
+ * 返回 null 表示模式未知(无租户 / 加载中 / 请求失败)—— 不再折叠成 'single':
45
+ * 'single' 仅表示服务端确认的小客户模式;请求失败经 react-query 错误通道暴露。
46
+ * (U97: 此前路径缺 /tenant 服务段 + 传 slug + catch 吞错 + 读 snake_case 键,
47
+ * 四连缺陷叠加使 strict 租户的审计 L2 限制恒不生效。)
38
48
  */
39
- export function useSodMode(): SodMode {
40
- const tenantSlug = useTenantSlug();
49
+ export function useSodMode(): SodMode | null {
50
+ const tenantId = useAuthStore((s) => s.currentTenantId);
41
51
 
42
52
  const { data: config } = useQuery<SodConfigResponse | null>({
43
- queryKey: ['sod-config', tenantSlug],
44
- queryFn: () => fetchSodConfig(tenantSlug),
53
+ queryKey: ['sod-config', tenantId],
54
+ queryFn: () => fetchSodConfig(tenantId),
45
55
  staleTime: 5 * 60 * 1000, // 5 min cache
46
56
  retry: 1,
47
- enabled: !!tenantSlug,
57
+ enabled: !!tenantId,
48
58
  });
49
59
 
50
- return config?.sod_mode ?? 'single';
60
+ return config?.sodMode ?? null;
51
61
  }
52
62
 
53
- async function fetchSodConfig(slug: string | undefined): Promise<SodConfigResponse | null> {
54
- if (!slug) return null;
55
- try {
56
- const res = await apiClient.get(`/api/v1/admin/tenants/${slug}/sod-config`);
57
- return extractItem<SodConfigResponse>(res.data);
58
- } catch {
59
- return null; // 默认 single
60
- }
63
+ async function fetchSodConfig(tenantId: string | null): Promise<SodConfigResponse | null> {
64
+ if (!tenantId) return null;
65
+ // 路径须循 /<服务>/api/v1/... 惯例(U97: 此前漏 /tenant 服务段 → 网关 404)
66
+ const res = await apiClient.get(API_PATHS.TENANT.SOD_CONFIG(tenantId));
67
+ return extractItem<SodConfigResponse>(res.data);
61
68
  }
62
69
 
63
70
  /**
64
71
  * 判断当前用户在 strict 模式下是否被限制查看审计详情。
72
+ * 模式未知(null)时不判定为受限(维持既有 fail-open 语义)。
65
73
  */
66
74
  export function useIsAuditRestricted(): boolean {
67
75
  const sodMode = useSodMode();