@autional-cn/shared 0.1.0-rc

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 (81) hide show
  1. package/LICENSE +661 -0
  2. package/package.json +47 -0
  3. package/src/api/__tests__/entry-plane.test.ts +119 -0
  4. package/src/api/bff-client.ts +94 -0
  5. package/src/api/client.ts +174 -0
  6. package/src/api/entry-plane.ts +61 -0
  7. package/src/api/trace.ts +21 -0
  8. package/src/auth/__tests__/ac007-role-source.test.ts +124 -0
  9. package/src/auth/__tests__/auth-machine.test.ts +157 -0
  10. package/src/auth/__tests__/build-login-url.test.ts +219 -0
  11. package/src/auth/__tests__/get-current-role.test.ts +61 -0
  12. package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
  13. package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
  14. package/src/auth/__tests__/session-client-id.test.ts +124 -0
  15. package/src/auth/__tests__/slug-from-url.test.ts +112 -0
  16. package/src/auth/auth-machine.ts +151 -0
  17. package/src/auth/auth-queries.ts +118 -0
  18. package/src/auth/build-tenant-urls.ts +48 -0
  19. package/src/auth/oauth-client-from-slug.ts +119 -0
  20. package/src/auth/oauth-client-id-store.ts +41 -0
  21. package/src/auth/oauth-login.ts +291 -0
  22. package/src/auth/pkce.ts +141 -0
  23. package/src/auth/portal-config.ts +68 -0
  24. package/src/auth/roles.ts +102 -0
  25. package/src/auth/service.ts +368 -0
  26. package/src/auth/slug-from-url.ts +92 -0
  27. package/src/auth/store.ts +226 -0
  28. package/src/auth/tenant-route-middleware.ts +113 -0
  29. package/src/auth/tenant-slug-context.tsx +9 -0
  30. package/src/branding/BrandingInitializer.tsx +60 -0
  31. package/src/branding/brand-color.ts +155 -0
  32. package/src/branding/branding-cache.ts +71 -0
  33. package/src/branding/extract-branding.ts +27 -0
  34. package/src/branding/index.ts +15 -0
  35. package/src/branding/tenant-store.ts +13 -0
  36. package/src/branding/types.ts +14 -0
  37. package/src/branding/useBranding.ts +85 -0
  38. package/src/components/AuditStatsOnly.tsx +65 -0
  39. package/src/components/OAuthCallbackPage.tsx +56 -0
  40. package/src/components/PortalGuard.tsx +105 -0
  41. package/src/components/RequireAuth.tsx +142 -0
  42. package/src/components/TenantIndexGuard.tsx +131 -0
  43. package/src/components/__tests__/RequireAuth.test.tsx +244 -0
  44. package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
  45. package/src/config/__tests__/portal-resolver.test.ts +247 -0
  46. package/src/config/__tests__/valid-redirect.test.ts +47 -0
  47. package/src/config/constants.ts +27 -0
  48. package/src/config/index.ts +411 -0
  49. package/src/config/resolve-basename.ts +57 -0
  50. package/src/constants/api-paths.ts +450 -0
  51. package/src/generated/api.ts +12566 -0
  52. package/src/generated/queries.ts +12623 -0
  53. package/src/generated/types.ts +13888 -0
  54. package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
  55. package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
  56. package/src/hooks/useAuth.ts +239 -0
  57. package/src/hooks/useBootstrap.ts +74 -0
  58. package/src/hooks/useCurrentRole.ts +18 -0
  59. package/src/hooks/useLogout.ts +33 -0
  60. package/src/hooks/usePermission.ts +116 -0
  61. package/src/hooks/useSodMode.ts +73 -0
  62. package/src/index.ts +209 -0
  63. package/src/lib/push.ts +41 -0
  64. package/src/seo/dom-utils.ts +94 -0
  65. package/src/seo/index.ts +6 -0
  66. package/src/seo/jsonld.ts +29 -0
  67. package/src/seo/types.ts +29 -0
  68. package/src/seo/usePageMeta.ts +17 -0
  69. package/src/seo/usePageTitle.ts +14 -0
  70. package/src/seo/useSEO.ts +80 -0
  71. package/src/types/index.ts +185 -0
  72. package/src/utils/__tests__/mock-test.ts +15 -0
  73. package/src/utils/browser.ts +6 -0
  74. package/src/utils/case.ts +44 -0
  75. package/src/utils/error.ts +26 -0
  76. package/src/utils/format.ts +63 -0
  77. package/src/utils/password-transmission.test.ts +145 -0
  78. package/src/utils/password-transmission.ts +253 -0
  79. package/src/utils/response.ts +62 -0
  80. package/src/utils/vite.ts +4 -0
  81. package/src/vite-env.d.ts +18 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * AuthMachine 单元测试
3
+ *
4
+ * 验证状态机 reducer 的纯函数逻辑:
5
+ * - 状态转换是否正确
6
+ * - 边界条件
7
+ * - 从不合法状态恢复
8
+ */
9
+ import { describe, it, expect } from 'vitest';
10
+
11
+ // AuthMachine 的 reducer 逻辑是纯函数,可以直接测试
12
+ type AuthStatus =
13
+ | 'checking'
14
+ | 'authenticated'
15
+ | 'bootstrap'
16
+ | 'ready'
17
+ | 'unauthenticated'
18
+ | 'redirecting';
19
+
20
+ interface MachineState {
21
+ status: AuthStatus;
22
+ hasToken: boolean;
23
+ }
24
+
25
+ type Action =
26
+ | { type: 'TOKEN_FOUND' }
27
+ | { type: 'NO_TOKEN' }
28
+ | { type: 'BOOTSTRAP_LOADING' }
29
+ | { type: 'BOOTSTRAP_READY' }
30
+ | { type: 'BOOTSTRAP_ERROR' }
31
+ | { type: 'SESSION_EXPIRED' }
32
+ | { type: 'REDIRECT' };
33
+
34
+ function authReducer(state: MachineState, action: Action): MachineState {
35
+ switch (action.type) {
36
+ case 'TOKEN_FOUND':
37
+ return { ...state, status: 'authenticated', hasToken: true };
38
+ case 'NO_TOKEN':
39
+ return { ...state, status: 'unauthenticated', hasToken: false };
40
+ case 'BOOTSTRAP_LOADING':
41
+ return state.status === 'authenticated' ? { ...state, status: 'bootstrap' } : state;
42
+ case 'BOOTSTRAP_READY':
43
+ return state.status === 'bootstrap' || state.status === 'authenticated'
44
+ ? { ...state, status: 'ready' }
45
+ : state;
46
+ case 'BOOTSTRAP_ERROR':
47
+ return state.status === 'bootstrap' ? { ...state, status: 'ready' } : state;
48
+ case 'SESSION_EXPIRED':
49
+ return { ...state, status: 'unauthenticated', hasToken: false };
50
+ case 'REDIRECT':
51
+ return { ...state, status: 'redirecting' };
52
+ default:
53
+ return state;
54
+ }
55
+ }
56
+
57
+ function createInitialState(): MachineState {
58
+ return { status: 'checking', hasToken: false };
59
+ }
60
+
61
+ describe('AuthMachine reducer', () => {
62
+ it('初始状态为 checking,无 token', () => {
63
+ const state = createInitialState();
64
+ expect(state.status).toBe('checking');
65
+ expect(state.hasToken).toBe(false);
66
+ });
67
+
68
+ it('TOKEN_FOUND → authenticated', () => {
69
+ const state = authReducer(createInitialState(), { type: 'TOKEN_FOUND' });
70
+ expect(state.status).toBe('authenticated');
71
+ expect(state.hasToken).toBe(true);
72
+ });
73
+
74
+ it('NO_TOKEN → unauthenticated', () => {
75
+ const state = authReducer(createInitialState(), { type: 'NO_TOKEN' });
76
+ expect(state.status).toBe('unauthenticated');
77
+ expect(state.hasToken).toBe(false);
78
+ });
79
+
80
+ it('authenticated + BOOTSTRAP_LOADING → bootstrap', () => {
81
+ const authenticated = authReducer(createInitialState(), { type: 'TOKEN_FOUND' });
82
+ const state = authReducer(authenticated, { type: 'BOOTSTRAP_LOADING' });
83
+ expect(state.status).toBe('bootstrap');
84
+ });
85
+
86
+ it('authenticated + BOOTSTRAP_READY → ready(跳过 bootstrap)', () => {
87
+ const authenticated = authReducer(createInitialState(), { type: 'TOKEN_FOUND' });
88
+ const state = authReducer(authenticated, { type: 'BOOTSTRAP_READY' });
89
+ expect(state.status).toBe('ready');
90
+ });
91
+
92
+ it('bootstrap + BOOTSTRAP_READY → ready', () => {
93
+ const authenticated = authReducer(createInitialState(), { type: 'TOKEN_FOUND' });
94
+ const bootstrap = authReducer(authenticated, { type: 'BOOTSTRAP_LOADING' });
95
+ const state = authReducer(bootstrap, { type: 'BOOTSTRAP_READY' });
96
+ expect(state.status).toBe('ready');
97
+ });
98
+
99
+ it('bootstrap + BOOTSTRAP_ERROR → ready(降级运行)', () => {
100
+ const authenticated = authReducer(createInitialState(), { type: 'TOKEN_FOUND' });
101
+ const bootstrap = authReducer(authenticated, { type: 'BOOTSTRAP_LOADING' });
102
+ const state = authReducer(bootstrap, { type: 'BOOTSTRAP_ERROR' });
103
+ expect(state.status).toBe('ready');
104
+ });
105
+
106
+ it('SESSION_EXPIRED → unauthenticated(从任何状态)', () => {
107
+ const ready = authReducer(
108
+ authReducer(authReducer(createInitialState(), { type: 'TOKEN_FOUND' }), {
109
+ type: 'BOOTSTRAP_READY',
110
+ }),
111
+ { type: 'SESSION_EXPIRED' },
112
+ );
113
+ expect(ready.status).toBe('unauthenticated');
114
+ expect(ready.hasToken).toBe(false);
115
+ });
116
+
117
+ it('unauthenticated + REDIRECT → redirecting', () => {
118
+ const unauth = authReducer(createInitialState(), { type: 'NO_TOKEN' });
119
+ const state = authReducer(unauth, { type: 'REDIRECT' });
120
+ expect(state.status).toBe('redirecting');
121
+ });
122
+
123
+ it('checking 态下 BOOTSTRAP_LOADING 不应改变状态', () => {
124
+ const state = authReducer(createInitialState(), { type: 'BOOTSTRAP_LOADING' });
125
+ expect(state.status).toBe('checking'); // 不变
126
+ });
127
+
128
+ it('checking 态下 BOOTSTRAP_READY 不应改变状态', () => {
129
+ const state = authReducer(createInitialState(), { type: 'BOOTSTRAP_READY' });
130
+ expect(state.status).toBe('checking'); // 不变
131
+ });
132
+
133
+ it('完整 happy path: checking → authenticated → bootstrap → ready', () => {
134
+ let state = createInitialState();
135
+ expect(state.status).toBe('checking');
136
+
137
+ state = authReducer(state, { type: 'TOKEN_FOUND' });
138
+ expect(state.status).toBe('authenticated');
139
+
140
+ state = authReducer(state, { type: 'BOOTSTRAP_LOADING' });
141
+ expect(state.status).toBe('bootstrap');
142
+
143
+ state = authReducer(state, { type: 'BOOTSTRAP_READY' });
144
+ expect(state.status).toBe('ready');
145
+ });
146
+
147
+ it('完整 unauthenticated path: checking → unauthenticated → redirecting', () => {
148
+ let state = createInitialState();
149
+ expect(state.status).toBe('checking');
150
+
151
+ state = authReducer(state, { type: 'NO_TOKEN' });
152
+ expect(state.status).toBe('unauthenticated');
153
+
154
+ state = authReducer(state, { type: 'REDIRECT' });
155
+ expect(state.status).toBe('redirecting');
156
+ });
157
+ });
@@ -0,0 +1,219 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { buildLoginUrl, buildLogoutUrl } from '../roles';
3
+
4
+ const MOCK_CONFIG = {
5
+ VITE_PORTAL_CONFIG: {
6
+ auth: { host: 'auth', base: '' },
7
+ },
8
+ };
9
+
10
+ function mockWindow(
11
+ hostname: string,
12
+ protocol: string,
13
+ origin: string,
14
+ config: any = MOCK_CONFIG,
15
+ ): void {
16
+ vi.stubGlobal('window', {
17
+ location: { hostname, protocol, origin },
18
+ __APP_CONFIG__: config,
19
+ });
20
+ }
21
+
22
+ describe('buildLoginUrl', () => {
23
+ beforeEach(() => {
24
+ vi.restoreAllMocks();
25
+ });
26
+
27
+ describe('basic URL construction', () => {
28
+ it('builds base login URL pointing to auth domain', () => {
29
+ mockWindow('app.iam.tianv.local', 'https:', 'https://app.iam.tianv.local');
30
+ const url = buildLoginUrl();
31
+ expect(url).toBe('https://auth.iam.tianv.local');
32
+ });
33
+
34
+ it('includes redirect param when returnUrl provided', () => {
35
+ mockWindow('app.iam.tianv.local', 'https:', 'https://app.iam.tianv.local');
36
+ const url = buildLoginUrl('https://app.iam.tianv.com/admin');
37
+ expect(url).toContain('redirect=');
38
+ expect(url).toContain(encodeURIComponent('https://app.iam.tianv.com/admin'));
39
+ });
40
+ });
41
+
42
+ describe('fromRequireAuth parameter', () => {
43
+ it('appends from_requireauth=1 when fromRequireAuth is true', () => {
44
+ mockWindow('app.iam.tianv.local', 'https:', 'https://app.iam.tianv.local');
45
+ const url = buildLoginUrl(undefined, true);
46
+ expect(url).toContain('from_requireauth=1');
47
+ });
48
+
49
+ it('appends from_requireauth=1 with redirect param', () => {
50
+ mockWindow('app.iam.tianv.local', 'https:', 'https://app.iam.tianv.local');
51
+ const url = buildLoginUrl('https://app.iam.tianv.com/admin', true);
52
+ expect(url).toContain('redirect=');
53
+ expect(url).toContain('from_requireauth=1');
54
+ expect(url).toContain('&from_requireauth=1');
55
+ });
56
+
57
+ it('does not include from_requireauth when fromRequireAuth is false', () => {
58
+ mockWindow('app.iam.tianv.local', 'https:', 'https://app.iam.tianv.local');
59
+ const url = buildLoginUrl('https://app.iam.tianv.com/admin', false);
60
+ expect(url).not.toContain('from_requireauth');
61
+ });
62
+
63
+ it('does not include from_requireauth when fromRequireAuth is undefined', () => {
64
+ mockWindow('app.iam.tianv.local', 'https:', 'https://app.iam.tianv.local');
65
+ const url = buildLoginUrl('https://app.iam.tianv.com/admin');
66
+ expect(url).not.toContain('from_requireauth');
67
+ });
68
+
69
+ it('returns only from_requireauth=1 when no returnUrl but fromRequireAuth is true', () => {
70
+ mockWindow('app.iam.tianv.local', 'https:', 'https://app.iam.tianv.local');
71
+ const url = buildLoginUrl(undefined, true);
72
+ expect(url).toContain('?from_requireauth=1');
73
+ expect(url).not.toContain('redirect=');
74
+ });
75
+ });
76
+
77
+ describe('domain-agnostic', () => {
78
+ it('works with .com domain', () => {
79
+ mockWindow('app.iam.tianv.com', 'https:', 'https://app.iam.tianv.com');
80
+ const url = buildLoginUrl();
81
+ expect(url).toBe('https://auth.iam.tianv.com');
82
+ });
83
+
84
+ it('works with autional.com domain', () => {
85
+ mockWindow('app.autional.com', 'https:', 'https://app.autional.com');
86
+ const url = buildLoginUrl();
87
+ expect(url).toBe('https://auth.autional.com');
88
+ });
89
+ });
90
+
91
+ describe('tenant-slug preservation', () => {
92
+ const AUTH_CONFIG = {
93
+ VITE_PORTAL_CONFIG: {
94
+ auth: { host: 'auth', base: '' },
95
+ },
96
+ };
97
+
98
+ it('redirects to {slug}/login when returnUrl is tenant-scoped on auth domain', () => {
99
+ mockWindow('auth.autional.local', 'https:', 'https://auth.autional.local', AUTH_CONFIG);
100
+ const url = buildLoginUrl('https://auth.autional.local/acme-corp/dashboard');
101
+ expect(url).toBe(
102
+ 'https://auth.autional.local/acme-corp/login?redirect=' +
103
+ encodeURIComponent('https://auth.autional.local/acme-corp/dashboard'),
104
+ );
105
+ });
106
+
107
+ it('keeps from_requireauth on tenant-scoped login url', () => {
108
+ mockWindow('auth.autional.local', 'https:', 'https://auth.autional.local', AUTH_CONFIG);
109
+ const url = buildLoginUrl('https://auth.autional.local/acme-corp/dashboard', true);
110
+ expect(url).toBe(
111
+ 'https://auth.autional.local/acme-corp/login?redirect=' +
112
+ encodeURIComponent('https://auth.autional.local/acme-corp/dashboard') +
113
+ '&from_requireauth=1',
114
+ );
115
+ });
116
+
117
+ it('does not treat reserved auth routes as tenant slug', () => {
118
+ mockWindow('auth.autional.local', 'https:', 'https://auth.autional.local', AUTH_CONFIG);
119
+ const url = buildLoginUrl('https://auth.autional.local/reset-password');
120
+ expect(url).toBe(
121
+ 'https://auth.autional.local?redirect=' +
122
+ encodeURIComponent('https://auth.autional.local/reset-password'),
123
+ );
124
+ });
125
+
126
+ it('preserves cross-domain returnUrl behavior (app domain)', () => {
127
+ mockWindow('auth.autional.local', 'https:', 'https://auth.autional.local', AUTH_CONFIG);
128
+ const url = buildLoginUrl('https://app.autional.com/admin');
129
+ expect(url).toBe(
130
+ 'https://auth.autional.local?redirect=' +
131
+ encodeURIComponent('https://app.autional.com/admin'),
132
+ );
133
+ });
134
+
135
+ it('does not truncate cross-domain returnUrl containing /login substring (regression P1-2)', () => {
136
+ mockWindow('auth.autional.local', 'https:', 'https://auth.autional.local', AUTH_CONFIG);
137
+ const url = buildLoginUrl('https://app.autional.com/admin/login');
138
+ expect(url).toBe(
139
+ 'https://auth.autional.local?redirect=' +
140
+ encodeURIComponent('https://app.autional.com/admin/login'),
141
+ );
142
+ });
143
+
144
+ it('truncates auth-domain login page itself (no nested redirect)', () => {
145
+ mockWindow('auth.autional.local', 'https:', 'https://auth.autional.local', AUTH_CONFIG);
146
+ const url = buildLoginUrl('https://auth.autional.local/acme-corp/login');
147
+ expect(url).toBe('https://auth.autional.local/acme-corp/login');
148
+ });
149
+ });
150
+
151
+ describe('useLogout unification (logout → login redirect)', () => {
152
+ const AUTH_CONFIG = {
153
+ VITE_PORTAL_CONFIG: {
154
+ auth: { host: 'auth', base: '' },
155
+ },
156
+ };
157
+
158
+ it('logout from auth-domain tenant page keeps tenant slug (bug scenario)', () => {
159
+ mockWindow('auth.autional.local', 'https:', 'https://auth.autional.local', AUTH_CONFIG);
160
+ const url = buildLoginUrl('https://auth.autional.local/acme-corp/dashboard');
161
+ expect(url).toBe(
162
+ 'https://auth.autional.local/acme-corp/login?redirect=' +
163
+ encodeURIComponent('https://auth.autional.local/acme-corp/dashboard'),
164
+ );
165
+ });
166
+
167
+ it('logout from auth root (tenant select page) does not add redirect', () => {
168
+ mockWindow('auth.autional.local', 'https:', 'https://auth.autional.local', AUTH_CONFIG);
169
+ const url = buildLoginUrl('https://auth.autional.local/');
170
+ expect(url).toBe('https://auth.autional.local');
171
+ });
172
+
173
+ it('logout from portal domain falls back to generic auth login (no slug misjudge)', () => {
174
+ // useLogout 在 portal app 中跨域调用:returnUrl origin ≠ auth origin,
175
+ // 首段 /admin 不会被误判为租户 slug,跳转 {base}?redirect=<portal-url>
176
+ mockWindow('app.autional.com', 'https:', 'https://app.autional.com', AUTH_CONFIG);
177
+ const url = buildLoginUrl('https://app.autional.com/admin');
178
+ expect(url).toBe(
179
+ 'https://auth.autional.com?redirect=' +
180
+ encodeURIComponent('https://app.autional.com/admin'),
181
+ );
182
+ });
183
+ });
184
+
185
+ describe('buildLogoutUrl(F-W5c 登出意图标记)', () => {
186
+ const AUTH_CONFIG = {
187
+ VITE_PORTAL_CONFIG: {
188
+ auth: { host: 'auth', base: '' },
189
+ },
190
+ };
191
+
192
+ it('门户域登出:auth 裸根 + redirect 回程 + logout=1', () => {
193
+ mockWindow('app.autional.com', 'https:', 'https://app.autional.com', AUTH_CONFIG);
194
+ const url = buildLogoutUrl('https://app.autional.com/demo/');
195
+ expect(url).toBe(
196
+ 'https://auth.autional.com/?redirect=' +
197
+ encodeURIComponent('https://app.autional.com/demo/') +
198
+ '&logout=1',
199
+ );
200
+ });
201
+
202
+ it('auth 域租户页登出也走裸根:不经 /<slug>/login(那里 logout=1 被静默忽略)', () => {
203
+ mockWindow('auth.autional.local', 'https:', 'https://auth.autional.local', AUTH_CONFIG);
204
+ const url = buildLogoutUrl('https://auth.autional.local/acme-corp/dashboard');
205
+ expect(url).toBe(
206
+ 'https://auth.autional.local/?redirect=' +
207
+ encodeURIComponent('https://auth.autional.local/acme-corp/dashboard') +
208
+ '&logout=1',
209
+ );
210
+ expect(url).not.toContain('/acme-corp/login');
211
+ });
212
+
213
+ it('无回程:auth 裸根 + logout=1', () => {
214
+ mockWindow('auth.autional.local', 'https:', 'https://auth.autional.local', AUTH_CONFIG);
215
+ const url = buildLogoutUrl();
216
+ expect(url).toBe('https://auth.autional.local/?logout=1');
217
+ });
218
+ });
219
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { useAuthStore, getCurrentRole } from '../store';
3
+
4
+ // 注:node 测试环境下 zustand persist 中间件会打印一条无害的 storage 降级警告
5
+ // ("Unable to update item 'authms-auth-v1'"),这是 store.ts 既有 persist 的
6
+ // 标准降级行为,不影响被测的 getCurrentRole 派生逻辑。
7
+
8
+ const TENANTS = [
9
+ { id: 'tenant-a', name: 'Tenant A', role: 'admin' },
10
+ { id: 'tenant-b', name: 'Tenant B', role: 'member' },
11
+ ];
12
+
13
+ /** 重置 store 到初始状态,避免跨用例污染(AC-002 约定) */
14
+ function resetAuthStore(): void {
15
+ useAuthStore.setState({
16
+ user: null,
17
+ accessToken: null,
18
+ refreshToken: null,
19
+ tenants: [],
20
+ currentTenantId: null,
21
+ permissions: [],
22
+ isAuthenticated: false,
23
+ });
24
+ }
25
+
26
+ describe('getCurrentRole (AC-001 四态派生,userinfo-first)', () => {
27
+ beforeEach(() => {
28
+ resetAuthStore();
29
+ });
30
+
31
+ it('① tenants 空 → null', () => {
32
+ useAuthStore.setState({ tenants: [], currentTenantId: 'tenant-a' });
33
+ expect(getCurrentRole()).toBeNull();
34
+ });
35
+
36
+ it('② currentTenantId null → null(不猜测)', () => {
37
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: null });
38
+ expect(getCurrentRole()).toBeNull();
39
+ });
40
+
41
+ it('③ 命中 → tenants.find(t => t.id === currentTenantId).role', () => {
42
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: 'tenant-a' });
43
+ expect(getCurrentRole()).toBe('admin');
44
+
45
+ useAuthStore.setState({ currentTenantId: 'tenant-b' });
46
+ expect(getCurrentRole()).toBe('member');
47
+ });
48
+
49
+ it('④ 租户切换后重查返回新角色(switchTenant)', () => {
50
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: 'tenant-a' });
51
+ expect(getCurrentRole()).toBe('admin');
52
+
53
+ useAuthStore.getState().switchTenant('tenant-b');
54
+ expect(getCurrentRole()).toBe('member');
55
+ });
56
+
57
+ it('currentTenantId 指向不存在的租户 → null(不猜测)', () => {
58
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: 'tenant-ghost' });
59
+ expect(getCurrentRole()).toBeNull();
60
+ });
61
+ });
@@ -0,0 +1,132 @@
1
+ // @vitest-environment jsdom
2
+ import '@testing-library/jest-dom/vitest';
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4
+ import { renderHook, cleanup, waitFor } from '@testing-library/react';
5
+
6
+ // ============================================================
7
+ // F-W6 判别依据:按 slug 拉取公开 auth-config 的三态
8
+ // - ok(2xx)
9
+ // - unknown-slug(**HTTP 404**,租户确定性不存在)→ 门户侧闸门判死
10
+ // - error(网络/5xx/解析失败)→ 必须 fail-open(not-found,仍走登录漏斗)
11
+ // 契约来源:identity public_handler.go GetTenantAuthConfigBySlug ——
12
+ // 租户不存在 → 404 ErrCodeNotFound;已知租户未配 client → 200(client 空)。
13
+ // ============================================================
14
+
15
+ import {
16
+ useOAuthClientIdFromUrl,
17
+ fetchOAuthClientIdBySlug,
18
+ } from '../oauth-client-from-slug';
19
+
20
+ function setPath(path: string) {
21
+ window.history.pushState({}, '', path);
22
+ }
23
+
24
+ function stubFetch(ok: boolean, status: number, body?: unknown) {
25
+ vi.stubGlobal(
26
+ 'fetch',
27
+ vi.fn().mockResolvedValue({
28
+ ok,
29
+ status,
30
+ json: async () => body,
31
+ }),
32
+ );
33
+ }
34
+
35
+ beforeEach(() => {
36
+ setPath('/acme');
37
+ delete (window as any).__APP_CONFIG__;
38
+ });
39
+
40
+ afterEach(() => {
41
+ cleanup();
42
+ vi.unstubAllGlobals();
43
+ });
44
+
45
+ describe('useOAuthClientIdFromUrl(三态判别)', () => {
46
+ it('HTTP 404 → unknown-slug(确定性不存在,带 slug)', async () => {
47
+ stubFetch(false, 404);
48
+ const { result } = renderHook(() => useOAuthClientIdFromUrl());
49
+
50
+ await waitFor(() => expect(result.current.status).toBe('unknown-slug'));
51
+ expect(result.current).toMatchObject({ status: 'unknown-slug', slug: 'acme' });
52
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining('by-slug/acme'));
53
+ });
54
+
55
+ it('HTTP 500 → not-found(fail-open,不得判死;回归锁)', async () => {
56
+ stubFetch(false, 500);
57
+ const { result } = renderHook(() => useOAuthClientIdFromUrl());
58
+
59
+ await waitFor(() => expect(result.current.status).toBe('not-found'));
60
+ });
61
+
62
+ it('网络异常 → not-found(fail-open)', async () => {
63
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
64
+ const { result } = renderHook(() => useOAuthClientIdFromUrl());
65
+
66
+ await waitFor(() => expect(result.current.status).toBe('not-found'));
67
+ });
68
+
69
+ it('200 + data.oauthClientId(camel)→ resolved', async () => {
70
+ stubFetch(true, 200, { data: { oauthClientId: 'cid-1', tenantId: 't1' } });
71
+ const { result } = renderHook(() => useOAuthClientIdFromUrl());
72
+
73
+ await waitFor(() => expect(result.current.status).toBe('resolved'));
74
+ expect(result.current).toMatchObject({
75
+ status: 'resolved',
76
+ slug: 'acme',
77
+ clientId: 'cid-1',
78
+ config: { tenantId: 't1' },
79
+ });
80
+ });
81
+
82
+ it('200 + 扁平 oauth_client_id(snake)→ resolved', async () => {
83
+ stubFetch(true, 200, { oauth_client_id: 'cid-snake' });
84
+ const { result } = renderHook(() => useOAuthClientIdFromUrl());
85
+
86
+ await waitFor(() => expect(result.current.status).toBe('resolved'));
87
+ expect(result.current).toMatchObject({ status: 'resolved', clientId: 'cid-snake' });
88
+ });
89
+
90
+ it('200 但无 client(已知租户未配)→ not-found(不误判 unknown-slug)', async () => {
91
+ stubFetch(true, 200, { data: { tenantId: 't1' } });
92
+ const { result } = renderHook(() => useOAuthClientIdFromUrl());
93
+
94
+ await waitFor(() => expect(result.current.status).toBe('not-found'));
95
+ });
96
+
97
+ it('根路径无 slug → not-found 且不发请求', async () => {
98
+ setPath('/');
99
+ stubFetch(true, 200, { data: { oauthClientId: 'cid-1' } });
100
+ const { result } = renderHook(() => useOAuthClientIdFromUrl());
101
+
102
+ await waitFor(() => expect(result.current.status).toBe('not-found'));
103
+ expect(fetch).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it('env 固定 client(admin-console 旁路)→ not-found 且不发请求', async () => {
107
+ setPath('/users');
108
+ (window as any).__APP_CONFIG__ = { VITE_OAUTH_CLIENT_ID: 'cid-env' };
109
+ stubFetch(true, 200, { data: { oauthClientId: 'cid-1' } });
110
+ const { result } = renderHook(() => useOAuthClientIdFromUrl());
111
+
112
+ await waitFor(() => expect(result.current.status).toBe('not-found'));
113
+ expect(fetch).not.toHaveBeenCalled();
114
+ });
115
+ });
116
+
117
+ describe('fetchOAuthClientIdBySlug(非 hook 版本,登录页回源)', () => {
118
+ it('200 + client → 返回 clientId', async () => {
119
+ stubFetch(true, 200, { data: { oauthClientId: 'cid-1' } });
120
+ await expect(fetchOAuthClientIdBySlug('acme')).resolves.toBe('cid-1');
121
+ });
122
+
123
+ it('404 → null', async () => {
124
+ stubFetch(false, 404);
125
+ await expect(fetchOAuthClientIdBySlug('acme')).resolves.toBeNull();
126
+ });
127
+
128
+ it('网络异常 → null', async () => {
129
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
130
+ await expect(fetchOAuthClientIdBySlug('acme')).resolves.toBeNull();
131
+ });
132
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generatePKCE } from '../pkce';
3
+
4
+ // RFC 7636 verifier charset — must stay in sync with generateRandomString in ../pkce.ts
5
+ const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
6
+ const CHAR_SET = new Set(CHARSET.split(''));
7
+
8
+ describe('generatePKCE (PRNG rejection sampling — no modulo bias)', () => {
9
+ it('produces RFC 7636-compliant verifier: 64 chars from A-Za-z0-9-._~', async () => {
10
+ for (let i = 0; i < 50; i++) {
11
+ const { verifier } = await generatePKCE();
12
+ expect(verifier).toHaveLength(64);
13
+ for (const ch of verifier) {
14
+ expect(CHAR_SET.has(ch), `unexpected char ${ch}`).toBe(true);
15
+ }
16
+ }
17
+ });
18
+
19
+ it('verifiers differ across calls (non-deterministic)', async () => {
20
+ const a = await generatePKCE();
21
+ const b = await generatePKCE();
22
+ expect(a.verifier).not.toBe(b.verifier);
23
+ });
24
+
25
+ it('each of the 66 chars is near-uniformly distributed (rejection sampling kills modulo bias)', async () => {
26
+ // 30k verifiers × 64 chars = 1.92M samples. With uniform distribution the
27
+ // per-char std dev is ~0.9% of the mean, so ±5% tolerance has ~7σ margin —
28
+ // never flakes, while a naive `byte % 66` would push chars 58-65 ~22%
29
+ // below the mean and be caught immediately.
30
+ const SAMPLES = 30_000;
31
+ const counts = new Array<number>(66).fill(0);
32
+ for (let i = 0; i < SAMPLES; i++) {
33
+ const { verifier } = await generatePKCE();
34
+ for (const ch of verifier) {
35
+ counts[CHARSET.indexOf(ch)]++;
36
+ }
37
+ }
38
+ const total = SAMPLES * 64;
39
+ const expectedFreq = 1 / 66;
40
+ for (let i = 0; i < 66; i++) {
41
+ const freq = counts[i] / total;
42
+ expect(
43
+ Math.abs(freq - expectedFreq),
44
+ `char ${CHARSET[i]} deviated ${(((freq - expectedFreq) / expectedFreq) * 100).toFixed(2)}% from uniform`,
45
+ ).toBeLessThan(0.05 * expectedFreq);
46
+ }
47
+ }, 30_000);
48
+ });