@autional-cn/shared 0.1.0-rc.5 → 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.5",
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,27 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { needsNamespaceHeader } from '../namespace';
3
+
4
+ describe('needsNamespaceHeader', () => {
5
+ it('密钥 admin 组端点(列表/子路径/查询)需注 X-Namespace', () => {
6
+ expect(needsNamespaceHeader('/secret/api/v1/admin/secrets')).toBe(true);
7
+ expect(needsNamespaceHeader('/secret/api/v1/admin/secrets/detail?key=a/b')).toBe(true);
8
+ expect(needsNamespaceHeader('/secret/api/v1/admin/secrets/batch-delete')).toBe(true);
9
+ expect(needsNamespaceHeader('/secret/api/v1/admin/secrets/policy')).toBe(true);
10
+ });
11
+
12
+ it('非密钥 admin 端点不注头', () => {
13
+ expect(needsNamespaceHeader('/notification/api/v1/admin/announcements')).toBe(false);
14
+ expect(needsNamespaceHeader('/tenant/api/v1/admin/tenants')).toBe(false);
15
+ });
16
+
17
+ it('secret 公开面/internal 面不注头(前端不消费)', () => {
18
+ expect(needsNamespaceHeader('/secret/public/jwt/public-key')).toBe(false);
19
+ expect(needsNamespaceHeader('/secret/api/v1/secret/public/transmission/public-key')).toBe(false);
20
+ expect(needsNamespaceHeader('/api/v1/internal/secret/jwt/keys')).toBe(false);
21
+ });
22
+
23
+ it('相对路径缺省(undefined/空)不注头', () => {
24
+ expect(needsNamespaceHeader(undefined)).toBe(false);
25
+ expect(needsNamespaceHeader('')).toBe(false);
26
+ });
27
+ });
package/src/api/client.ts CHANGED
@@ -9,9 +9,10 @@
9
9
  import axios, { AxiosError } from 'axios';
10
10
  import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
11
11
  import { camelCaseKeys, snakeCaseKeys } from '../utils/case';
12
- import { getApiBaseUrl } from '../config';
12
+ import { API_NAMESPACE, getApiBaseUrl } from '../config';
13
13
  import { getTraceParentHeader } from './trace';
14
14
  import { ENTRY_PLANE_HEADER, resolveEntryPlane } from './entry-plane';
15
+ import { NAMESPACE_HEADER, needsNamespaceHeader } from './namespace';
15
16
  import { AuthService } from '../auth/service';
16
17
 
17
18
  function setupInterceptors(instance: AxiosInstance) {
@@ -39,6 +40,11 @@ function setupInterceptors(instance: AxiosInstance) {
39
40
  config.headers['X-Tenant-ID'] = tenantId;
40
41
  }
41
42
 
43
+ // secret 管理面(§13-A P1-B):admin 组端点 fail-closed 要求 X-Namespace ∈ S
44
+ if (needsNamespaceHeader(config.url)) {
45
+ config.headers[NAMESPACE_HEADER] = API_NAMESPACE;
46
+ }
47
+
42
48
  // 请求体 camelCase → snake_case 转换(后端 Go 期望 snake_case)
43
49
  if (config.data && typeof config.data === 'object' && !(config.data instanceof FormData)) {
44
50
  config.data = snakeCaseKeys(config.data);
@@ -0,0 +1,14 @@
1
+ /**
2
+ * secret 服务管理面 namespace(§13-A P1-B / SEC-204/207):
3
+ * `/secret/api/v1/admin*` 端点 fail-closed 要求 `X-Namespace ∈ 实例服务集合 S`
4
+ * (NamespaceMiddleware,缺头/未服务 → 400)。值取部署配置 VITE_API_NAMESPACE
5
+ * (见 config,缺省 "dev" 与服务端 DefaultNamespace 对称)。
6
+ */
7
+
8
+ export const NAMESPACE_HEADER = 'X-Namespace';
9
+
10
+ export const SECRET_ADMIN_PATH_PREFIX = '/secret/api/v1/admin';
11
+
12
+ export function needsNamespaceHeader(url?: string): boolean {
13
+ return !!url && url.includes(SECRET_ADMIN_PATH_PREFIX);
14
+ }
@@ -356,6 +356,10 @@ export function getPLATFORM_CONSOLE_URL(): string {
356
356
  }
357
357
  export const API_BASE_URL = getEnv('VITE_API_BASE_URL', '/bff');
358
358
 
359
+ // secret 管理面 namespace(§13-A P1-B):admin 组端点要求 X-Namespace ∈ S(fail-closed);
360
+ // 缺省 "dev" 与服务端 DefaultNamespace(service-core cross/registry keyid.go)对称
361
+ export const API_NAMESPACE = getEnv('VITE_API_NAMESPACE', 'dev');
362
+
359
363
  export function getApiBaseUrl(): string {
360
364
  return API_BASE_URL;
361
365
  }
@@ -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();
package/src/vite-env.d.ts CHANGED
@@ -9,6 +9,7 @@ interface ImportMetaEnv {
9
9
  readonly VITE_OAUTH_CLIENT_ID: string;
10
10
  readonly VITE_AUTH_PAGES_URL: string;
11
11
  readonly VITE_API_BASE_URL: string;
12
+ readonly VITE_API_NAMESPACE: string;
12
13
  readonly VITE_AUDIT_BASE_URL: string;
13
14
  [key: string]: string | boolean | undefined;
14
15
  }