@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,91 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4
+ import { renderHook, cleanup } from '@testing-library/react';
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6
+
7
+ // ============================================================
8
+ // F-W5b 回归锁:useBootstrap 无会话时必须 'idle',不得 'loading'
9
+ // —— platform-console LayoutWrapper 以 bootstrap==='loading' 挡 Outlet,
10
+ // 而 RequireAuth(唯一登录交棒点)在 Outlet 内 ⇒ 无会话冷启动永停 Spin。
11
+ // ============================================================
12
+
13
+ const { mockQueries } = vi.hoisted(() => ({
14
+ mockQueries: {
15
+ perms: { data: undefined as unknown, isError: false },
16
+ tenants: { data: undefined as unknown, isError: false },
17
+ },
18
+ }));
19
+
20
+ vi.mock('../../auth/auth-queries', () => ({
21
+ usePermissionsQuery: () => mockQueries.perms,
22
+ useTenantsQuery: () => mockQueries.tenants,
23
+ }));
24
+
25
+ import { useBootstrap } from '../useBootstrap';
26
+ import { useAuthStore } from '../../auth/store';
27
+
28
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
29
+
30
+ function wrapper({ children }: { children: React.ReactNode }) {
31
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
32
+ }
33
+
34
+ function resetAuthStore(): void {
35
+ useAuthStore.setState({
36
+ user: null,
37
+ accessToken: null,
38
+ refreshToken: null,
39
+ tenants: [],
40
+ currentTenantId: null,
41
+ permissions: [],
42
+ isAuthenticated: false,
43
+ });
44
+ window.localStorage.clear();
45
+ }
46
+
47
+ describe('useBootstrap(F-W5b 无会话语义)', () => {
48
+ beforeEach(() => {
49
+ vi.clearAllMocks();
50
+ resetAuthStore();
51
+ mockQueries.perms = { data: undefined, isError: false };
52
+ mockQueries.tenants = { data: undefined, isError: false };
53
+ });
54
+
55
+ afterEach(() => {
56
+ cleanup();
57
+ resetAuthStore();
58
+ });
59
+
60
+ it('无 token → idle(回归锁:返回 loading 会让 Layout 消费者永停 Spin)', () => {
61
+ const { result } = renderHook(() => useBootstrap(), { wrapper });
62
+ expect(result.current).toBe('idle');
63
+ expect(result.current).not.toBe('loading');
64
+ });
65
+
66
+ it('无 token 且 store 残留数据 → 仍 idle(登出/切换租户后的残态不得冒充 ready)', () => {
67
+ useAuthStore.setState({
68
+ accessToken: null,
69
+ permissions: ['user.read'],
70
+ tenants: [{ id: 't1', name: 'demo', role: 'member' }],
71
+ });
72
+ const { result } = renderHook(() => useBootstrap(), { wrapper });
73
+ expect(result.current).toBe('idle');
74
+ });
75
+
76
+ it('有 token + store 已缓存权限与租户 → ready', () => {
77
+ useAuthStore.setState({
78
+ accessToken: 'token-xyz',
79
+ permissions: ['user.read'],
80
+ tenants: [{ id: 't1', name: 'demo', role: 'member' }],
81
+ });
82
+ const { result } = renderHook(() => useBootstrap(), { wrapper });
83
+ expect(result.current).toBe('ready');
84
+ });
85
+
86
+ it('有 token 但查询尚未到达 → loading(保持既有等待语义)', () => {
87
+ useAuthStore.setState({ accessToken: 'token-xyz' });
88
+ const { result } = renderHook(() => useBootstrap(), { wrapper });
89
+ expect(result.current).toBe('loading');
90
+ });
91
+ });
@@ -0,0 +1,122 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
+ import { renderHook, act, cleanup } from '@testing-library/react';
4
+ import { useAuthStore } from '../../auth/store';
5
+ import { useCurrentRole } from '../useCurrentRole';
6
+
7
+ const TENANTS = [
8
+ { id: 'tenant-a', name: 'Tenant A', role: 'admin' },
9
+ { id: 'tenant-b', name: 'Tenant B', role: 'member' },
10
+ ];
11
+
12
+ /** 重置 store + localStorage,避免跨用例污染(AC-002 约定) */
13
+ function resetAuthStore(): void {
14
+ useAuthStore.setState({
15
+ user: null,
16
+ accessToken: null,
17
+ refreshToken: null,
18
+ tenants: [],
19
+ currentTenantId: null,
20
+ permissions: [],
21
+ isAuthenticated: false,
22
+ });
23
+ window.localStorage.clear();
24
+ }
25
+
26
+ describe('useCurrentRole (AC-002 响应式,zustand selector 订阅 store 本身)', () => {
27
+ beforeEach(() => {
28
+ resetAuthStore();
29
+ });
30
+
31
+ afterEach(() => {
32
+ cleanup();
33
+ resetAuthStore();
34
+ });
35
+
36
+ it('初始空 store → null', () => {
37
+ const { result } = renderHook(() => useCurrentRole());
38
+ expect(result.current).toBeNull();
39
+ });
40
+
41
+ it('渲染后直写 store(setState)→ re-render + 新值', () => {
42
+ const { result } = renderHook(() => useCurrentRole());
43
+ expect(result.current).toBeNull();
44
+
45
+ act(() => {
46
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: 'tenant-a' });
47
+ });
48
+ expect(result.current).toBe('admin');
49
+
50
+ act(() => {
51
+ useAuthStore.setState({ currentTenantId: 'tenant-b' });
52
+ });
53
+ expect(result.current).toBe('member');
54
+ });
55
+
56
+ it('updateTenants / updateCurrentTenant action 后重渲染新值', () => {
57
+ const { result } = renderHook(() => useCurrentRole());
58
+ expect(result.current).toBeNull();
59
+
60
+ act(() => {
61
+ useAuthStore.getState().setTenants(TENANTS);
62
+ });
63
+ expect(result.current).toBeNull(); // 有租户但未选 → 不猜测
64
+
65
+ act(() => {
66
+ useAuthStore.getState().setCurrentTenant('tenant-b');
67
+ });
68
+ expect(result.current).toBe('member');
69
+
70
+ act(() => {
71
+ useAuthStore.getState().switchTenant('tenant-a');
72
+ });
73
+ expect(result.current).toBe('admin');
74
+ });
75
+
76
+ it('logout 清空(tenants [] / currentTenantId null)→ null', () => {
77
+ const { result } = renderHook(() => useCurrentRole());
78
+
79
+ act(() => {
80
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: 'tenant-a' });
81
+ });
82
+ expect(result.current).toBe('admin');
83
+
84
+ act(() => {
85
+ useAuthStore.getState().clearAuth();
86
+ });
87
+ expect(result.current).toBeNull();
88
+ });
89
+
90
+ it('selector 原语稳定:无关字段变更不触发 re-render(Object.is 比较)', () => {
91
+ let renderCount = 0;
92
+ const { result } = renderHook(() => {
93
+ renderCount++;
94
+ return useCurrentRole();
95
+ });
96
+
97
+ act(() => {
98
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: 'tenant-a' });
99
+ });
100
+ expect(result.current).toBe('admin');
101
+ const afterRoleSet = renderCount;
102
+
103
+ // 无关字段变更(permissions)→ selector 结果相同 → 不 re-render
104
+ act(() => {
105
+ useAuthStore.setState({ permissions: ['tenant:user:read'] });
106
+ });
107
+ expect(result.current).toBe('admin');
108
+ expect(renderCount).toBe(afterRoleSet);
109
+
110
+ // 相同 role、不同数组引用 → 返回值仍是 string 原语 → 不 re-render
111
+ act(() => {
112
+ useAuthStore.setState({
113
+ tenants: [
114
+ { id: 'tenant-a', name: 'Tenant A', role: 'admin' },
115
+ { id: 'tenant-b', name: 'Tenant B', role: 'member' },
116
+ ],
117
+ });
118
+ });
119
+ expect(result.current).toBe('admin');
120
+ expect(renderCount).toBe(afterRoleSet);
121
+ });
122
+ });
@@ -0,0 +1,38 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach } from 'vitest';
3
+ import { renderHook } from '@testing-library/react';
4
+ import { useAuthStore } from '../../auth/store';
5
+ import { useCurrentTenantIdOr } from '../useCurrentTenantIdOr';
6
+
7
+ // 这个 hook 存在的理由就是**回落链的三段**,所以三段各验一条:
8
+ // 已选租户 → 用它;未选但列表非空 → tenants[0](BUG-013 的修法);都没有 → 调用方给的 fallback。
9
+ // 顺带锁死「同名两种语义」那个坑:platform 原来直接返回 fallback、不取 tenants[0]。
10
+ const T = (id: string) => ({ id, name: id, role: 'admin' });
11
+
12
+ describe('useCurrentTenantIdOr', () => {
13
+ beforeEach(() => {
14
+ useAuthStore.setState({ currentTenantId: null, tenants: [] } as never);
15
+ });
16
+
17
+ it('已选租户时用当前租户', () => {
18
+ useAuthStore.setState({ currentTenantId: 't-current', tenants: [T('t-current'), T('t-other')] } as never);
19
+ const { result } = renderHook(() => useCurrentTenantIdOr('fallback'));
20
+ expect(result.current).toBe('t-current');
21
+ });
22
+
23
+ it('未选租户但列表非空:回落 tenants[0](不直接用 fallback)', () => {
24
+ useAuthStore.setState({ currentTenantId: null, tenants: [T('t-first'), T('t-second')] } as never);
25
+ const { result } = renderHook(() => useCurrentTenantIdOr('fallback'));
26
+ expect(result.current).toBe('t-first');
27
+ });
28
+
29
+ it('连租户列表都空:才用调用方给的 fallback', () => {
30
+ const { result } = renderHook(() => useCurrentTenantIdOr('fallback'));
31
+ expect(result.current).toBe('fallback');
32
+ });
33
+
34
+ it('fallback 传空串时得到空串(「无租户」的一种显式表示)', () => {
35
+ const { result } = renderHook(() => useCurrentTenantIdOr(''));
36
+ expect(result.current).toBe('');
37
+ });
38
+ });
@@ -0,0 +1,61 @@
1
+ // @vitest-environment jsdom
2
+ // ============================================================
3
+ // AUTH-40:useLogout 落点口径锁——
4
+ // ① 缺省回程 = 捕获的当前 URL(EntryRouter 据此解析租户 → /<slug>/login)
5
+ // ② returnUrl 覆盖:裸 /logout 无 URL 租户段时由调用方补回上下文
6
+ // ③ 顺序契约:先终结会话(AuthService.logout)再导航
7
+ // ============================================================
8
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
9
+ import { renderHook } from '@testing-library/react';
10
+
11
+ const { mockLogout, mockTraceRedirect } = vi.hoisted(() => ({
12
+ mockLogout: vi.fn(() => Promise.resolve()),
13
+ mockTraceRedirect: vi.fn(),
14
+ }));
15
+
16
+ vi.mock('../../auth/service', () => ({ AuthService: { logout: mockLogout } }));
17
+ vi.mock('../../auth/auth-trace', () => ({ traceRedirect: mockTraceRedirect }));
18
+ vi.mock('../../config', () => ({
19
+ getPortalUrl: (portal: string) => (portal === 'auth' ? 'https://auth.example.com' : ''),
20
+ }));
21
+
22
+ import { useLogout } from '../useLogout';
23
+
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ window.history.replaceState(null, '', '/demo/dashboard');
27
+ });
28
+
29
+ describe('useLogout 落点口径(AUTH-40)', () => {
30
+ it('缺省:回程 = 当前 URL(带租户段),先登出后导航', async () => {
31
+ const { result } = renderHook(() => useLogout());
32
+ result.current();
33
+
34
+ await vi.waitFor(() => expect(mockTraceRedirect).toHaveBeenCalledTimes(1));
35
+ expect(mockLogout).toHaveBeenCalledTimes(1);
36
+
37
+ const [url, opts] = mockTraceRedirect.mock.calls[0];
38
+ expect(url).toBe(
39
+ 'https://auth.example.com/?redirect=' +
40
+ encodeURIComponent('http://localhost:3000/demo/dashboard') +
41
+ '&logout=1',
42
+ );
43
+ expect(opts).toMatchObject({ reason: 'logout', assign: true });
44
+ expect(mockLogout.mock.invocationCallOrder[0]).toBeLessThan(
45
+ mockTraceRedirect.mock.invocationCallOrder[0] as number,
46
+ );
47
+ });
48
+
49
+ it('returnUrl 覆盖:裸 /logout 用调用方补回的租户上下文(无渲染页 URL 依赖)', async () => {
50
+ const { result } = renderHook(() => useLogout({ returnUrl: '/demo/dashboard' }));
51
+ result.current();
52
+
53
+ await vi.waitFor(() => expect(mockTraceRedirect).toHaveBeenCalledTimes(1));
54
+ const [url] = mockTraceRedirect.mock.calls[0];
55
+ expect(url).toBe(
56
+ 'https://auth.example.com/?redirect=' +
57
+ encodeURIComponent('/demo/dashboard') +
58
+ '&logout=1',
59
+ );
60
+ });
61
+ });
@@ -0,0 +1,156 @@
1
+ // @vitest-environment jsdom
2
+ // TASK-AB1-12(fix-admin-b1-guard-contract / A-444①③ · RC-6 · D4 补二裁一):
3
+ // PERMISSION_MAP 映射层回归锁 —— 观测口径 = can()(hook 不暴露 effectivePerms,can 即映射结果函数)。
4
+ // 码集 = service-rbac db/seeds/platform_rbac.go 实际 seed:
5
+ // security_admin(11 码)/ user_manager / member / guest。
6
+ //
7
+ // 覆盖:B1 裁撤(user:read 去 tenant:user:read)+ B2 裁撤(role:read 去 tenant:role:read)
8
+ // + D4 补源(agent/robot/device:read → tenant:nhi:read;status:read → tenant:monitor:read;
9
+ // oauth:read → tenant:oauth:read)+ billing 不补(负向)。
10
+
11
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
12
+ import { renderHook, act, cleanup } from '@testing-library/react';
13
+ import { useAuthStore } from '../../auth/store';
14
+ import { usePermission } from '../usePermission';
15
+
16
+ const SECURITY_ADMIN_CODES = [
17
+ 'audit:read',
18
+ 'compliance:read',
19
+ 'role:read',
20
+ 'session:read',
21
+ 'user:read',
22
+ 'status:read',
23
+ 'oauth:read',
24
+ 'agent:read',
25
+ 'robot:read',
26
+ 'robot:health:read',
27
+ 'device:read',
28
+ ];
29
+ const USER_MANAGER_CODES = ['profile:read', 'user:create', 'user:delete', 'user:read', 'user:update'];
30
+ const MEMBER_CODES = ['profile:read', 'user:read'];
31
+ const GUEST_CODES = ['user:read'];
32
+
33
+ /** 重置 store + localStorage,避免跨用例污染。 */
34
+ function resetAuthStore(): void {
35
+ useAuthStore.setState({
36
+ user: null,
37
+ accessToken: null,
38
+ refreshToken: null,
39
+ tenants: [],
40
+ currentTenantId: null,
41
+ permissions: [],
42
+ isAuthenticated: false,
43
+ });
44
+ window.localStorage.clear();
45
+ }
46
+
47
+ /** 挂载 hook 并以非 admin 角色播种码集(admin 角色会走 tenant:* 前缀豁免,无法判别映射)。 */
48
+ function mountCan(role: string, permissions: string[]) {
49
+ const { result } = renderHook(() => usePermission());
50
+ act(() => {
51
+ useAuthStore.setState({
52
+ tenants: [{ id: 'tenant-a', name: 'Tenant A', role }],
53
+ currentTenantId: 'tenant-a',
54
+ permissions,
55
+ });
56
+ });
57
+ return (p: string) => result.current.can(p);
58
+ }
59
+
60
+ describe('AB1-12 映射表(AC-AB1-24)', () => {
61
+ beforeEach(() => {
62
+ resetAuthStore();
63
+ });
64
+
65
+ afterEach(() => {
66
+ cleanup();
67
+ resetAuthStore();
68
+ });
69
+
70
+ describe('B1 裁撤:user:read 去掉 tenant:user:read', () => {
71
+ it('user_manager 经 user:create 仍见 /users(不回归)', () => {
72
+ const can = mountCan('user_manager', USER_MANAGER_CODES);
73
+ expect(can('tenant:user:read')).toBe(true);
74
+ });
75
+
76
+ it('user_manager 保留 user:read 的其余映射(dashboard/self:profile)', () => {
77
+ const can = mountCan('user_manager', USER_MANAGER_CODES);
78
+ expect(can('tenant:dashboard:read')).toBe(true);
79
+ expect(can('self:profile:read')).toBe(true);
80
+ });
81
+
82
+ it('member 不显 /users(仅 user:read,无映射源)', () => {
83
+ const can = mountCan('member', MEMBER_CODES);
84
+ expect(can('tenant:user:read')).toBe(false);
85
+ });
86
+
87
+ it('member 仍持 user:read 的保留映射(dashboard)——裁撤只摘一个目标键', () => {
88
+ const can = mountCan('member', MEMBER_CODES);
89
+ expect(can('tenant:dashboard:read')).toBe(true);
90
+ });
91
+
92
+ it('guest 不显 /users', () => {
93
+ const can = mountCan('guest', GUEST_CODES);
94
+ expect(can('tenant:user:read')).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe('B2 裁撤:role:read 去掉 tenant:role:read', () => {
99
+ it('security_admin 不显 /roles 与 /role-activations(同键双面)', () => {
100
+ const can = mountCan('security_admin', SECURITY_ADMIN_CODES);
101
+ expect(can('tenant:role:read')).toBe(false);
102
+ });
103
+
104
+ it('security_admin 的 audit/compliance 读面不回归(audit:read 映射保留)', () => {
105
+ const can = mountCan('security_admin', SECURITY_ADMIN_CODES);
106
+ expect(can('tenant:audit:read')).toBe(true);
107
+ expect(can('tenant:compliance:read')).toBe(true);
108
+ });
109
+ });
110
+
111
+ describe('D4 补源:三组新映射逐码可授予', () => {
112
+ it('agent:read / robot:read / device:read 各自可授予 tenant:nhi:read(NHI 父键+叶)', () => {
113
+ for (const code of ['agent:read', 'robot:read', 'device:read']) {
114
+ const can = mountCan('member', [code]);
115
+ expect(can('tenant:nhi:read'), `code=${code}`).toBe(true);
116
+ }
117
+ });
118
+
119
+ it('status:read ⇒ tenant:monitor:read(/status)', () => {
120
+ const can = mountCan('security_admin', ['status:read']);
121
+ expect(can('tenant:monitor:read')).toBe(true);
122
+ });
123
+
124
+ it('oauth:read ⇒ tenant:oauth:read(/oauth-clients)', () => {
125
+ const can = mountCan('security_admin', ['oauth:read']);
126
+ expect(can('tenant:oauth:read')).toBe(true);
127
+ });
128
+
129
+ it('security_admin 全码集下三组补源同时生效', () => {
130
+ const can = mountCan('security_admin', SECURITY_ADMIN_CODES);
131
+ expect(can('tenant:nhi:read')).toBe(true);
132
+ expect(can('tenant:monitor:read')).toBe(true);
133
+ expect(can('tenant:oauth:read')).toBe(true);
134
+ });
135
+ });
136
+
137
+ describe('负向:不越权 + billing 不补', () => {
138
+ it('补源不外溢:status:read 不授予 tenant:user:read / tenant:role:read / tenant:nhi:read', () => {
139
+ const can = mountCan('security_admin', ['status:read']);
140
+ expect(can('tenant:user:read')).toBe(false);
141
+ expect(can('tenant:role:read')).toBe(false);
142
+ expect(can('tenant:nhi:read')).toBe(false);
143
+ });
144
+
145
+ it('billing 不补(D4 裁撤):security_admin 全码集也拿不到 tenant:billing:read', () => {
146
+ const can = mountCan('security_admin', SECURITY_ADMIN_CODES);
147
+ expect(can('tenant:billing:read')).toBe(false);
148
+ });
149
+
150
+ it('security_admin 全码集下 tenant:user:read / tenant:role:read 均为 false(双裁撤复核)', () => {
151
+ const can = mountCan('security_admin', SECURITY_ADMIN_CODES);
152
+ expect(can('tenant:user:read')).toBe(false);
153
+ expect(can('tenant:role:read')).toBe(false);
154
+ });
155
+ });
156
+ });