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

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,49 @@
1
+ // @vitest-environment jsdom
2
+ // PL-70 回归锁:JWT 段是 base64url(`-`/`_` 替代 `+`/`/`、无填充),裸 atob 对含 `-`/`_`
3
+ // 的载荷抛 InvalidCharacterError → store.isTokenExpired 误判「已过期」→ 请求拦截器
4
+ // 预判式刷新 → 无 RT 的模拟会话被清会话弹登录。旧套件用 btoa 直造「标准 base64」载荷,
5
+ // 恰好覆盖不到此形态(preflight-refresh.test.ts 的 expiredJwt 即此盲区)。
6
+ import { describe, it, expect } from 'vitest';
7
+ import { decodeJwtPayload } from '../jwt-payload';
8
+ import { isTokenExpired } from '../store';
9
+
10
+ // 造 base64url 载荷段。pad 连排 9 个 '?'(0x3F):任意连续 3 个字节位置中恰有一个
11
+ // 全局下标 ≡2 (mod 3),其 6bit 组 = 低 6 位 111111 → base64 字符 63 → base64url `_`
12
+ // ——保证段内定产 `_`,用例真实覆盖 base64url 形态而非碰运气。
13
+ function makeToken(payload: Record<string, unknown>): { token: string; segment: string } {
14
+ const json = JSON.stringify({ ...payload, pad: '?????????' });
15
+ let binary = '';
16
+ for (const b of Array.from(Buffer.from(json, 'utf8'))) binary += String.fromCharCode(b);
17
+ const segment = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
18
+ return { token: `eyJhbGciOiJIUzI1NiJ9.${segment}.sig`, segment };
19
+ }
20
+
21
+ describe('decodeJwtPayload(base64url 归一化)', () => {
22
+ it('含 -/_ 的 base64url 载荷可解,且非 ASCII 正确还原', () => {
23
+ const { token, segment } = makeToken({ exp: 4102444800, n: 'ÿ中文' });
24
+ expect(segment).toMatch(/[-_]/);
25
+ const payload = decodeJwtPayload(token);
26
+ expect(payload?.n).toBe('ÿ中文');
27
+ expect(payload?.exp).toBe(4102444800);
28
+ });
29
+
30
+ it('畸形输入 → null(不抛)', () => {
31
+ expect(decodeJwtPayload('not-a-jwt')).toBeNull();
32
+ expect(decodeJwtPayload('a.b')).toBeNull();
33
+ expect(decodeJwtPayload('a.!!!.c')).toBeNull();
34
+ expect(decodeJwtPayload('a.' + btoa('not json') + '.c')).toBeNull();
35
+ });
36
+
37
+ it('isTokenExpired:base64url 载荷 + 未来 exp → false(旧实现误判 true 的事故形态)', () => {
38
+ const exp = Math.floor(Date.now() / 1000) + 3600;
39
+ const { token, segment } = makeToken({ exp, n: 'ÿ' });
40
+ expect(segment).toMatch(/[-_]/);
41
+ expect(isTokenExpired(token)).toBe(false);
42
+ });
43
+
44
+ it('isTokenExpired:过期 exp → true;畸形 token → true', () => {
45
+ const { token } = makeToken({ exp: Math.floor(Date.now() / 1000) - 60, n: 'ÿ' });
46
+ expect(isTokenExpired(token)).toBe(true);
47
+ expect(isTokenExpired('not-a-jwt')).toBe(true);
48
+ });
49
+ });
@@ -0,0 +1,44 @@
1
+ // @vitest-environment jsdom
2
+ // U350 回归锁:logout(redirectTo) 接通导航语义 —— 先清会话(await AuthService.logout()),
3
+ // 再整页前往(traceRedirect assign);无参调用保持「仅清会话、导航交调用方」。
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
5
+
6
+ const { logoutSpy, traceRedirectSpy } = vi.hoisted(() => ({
7
+ logoutSpy: vi.fn(async () => {}),
8
+ traceRedirectSpy: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('../service', () => ({
12
+ AuthService: { logout: logoutSpy },
13
+ }));
14
+ vi.mock('../auth-trace', () => ({
15
+ traceRedirect: traceRedirectSpy,
16
+ }));
17
+
18
+ import { logout } from '../store';
19
+
20
+ describe('U350: shared logout(redirectTo) 导航语义', () => {
21
+ beforeEach(() => {
22
+ logoutSpy.mockClear();
23
+ traceRedirectSpy.mockClear();
24
+ });
25
+
26
+ it('带 redirectTo:先清会话,再整页前往(reason=logout, assign)', async () => {
27
+ await logout('/auth/acme-corp/login?account_deleted=true');
28
+ expect(logoutSpy).toHaveBeenCalledTimes(1);
29
+ expect(traceRedirectSpy).toHaveBeenCalledWith(
30
+ '/auth/acme-corp/login?account_deleted=true',
31
+ { reason: 'logout', assign: true },
32
+ );
33
+ // 顺序锁:导航必须发生在会话清理之后(先终结会话再离开)
34
+ expect(logoutSpy.mock.invocationCallOrder[0]).toBeLessThan(
35
+ traceRedirectSpy.mock.invocationCallOrder[0],
36
+ );
37
+ });
38
+
39
+ it('无参:仅清会话、不导航(程序性调用方不受影响)', async () => {
40
+ await logout();
41
+ expect(logoutSpy).toHaveBeenCalledTimes(1);
42
+ expect(traceRedirectSpy).not.toHaveBeenCalled();
43
+ });
44
+ });
@@ -0,0 +1,219 @@
1
+ // @vitest-environment jsdom
2
+ // ============================================================
3
+ // AUTH-15:登出吊销分链(identity vs OAuth PKCE)——
4
+ // ① OAuth 链(不透明 rt + client_id)→ oauth revoke ×2(at/rt)带 client_id
5
+ // ② identity 链(JWT rt / 无 client_id)→ identity /auth/logout 携 Bearer AT
6
+ // ③ JWT rt + 持久 client_id 仍走 identity(镜像 refreshToken 分链判据)
7
+ // ④ AUTH-08 登出卫生:localStorage 四键 + OAuth 预置物 + trace 缓冲
8
+ // ⑤ 吊销失败 → trace 留痕 failed(HTTP status / network-error)且 logout 仍 resolve
9
+ // ============================================================
10
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
11
+
12
+ const { mockCtx, mockClearAuth, mockTraceEvent, mockTraceClear, mockDiscard, mockBffLogout, mockPost } =
13
+ vi.hoisted(() => ({
14
+ mockCtx: {
15
+ accessToken: null as string | null,
16
+ refreshToken: null as string | null,
17
+ clientId: '',
18
+ },
19
+ mockClearAuth: vi.fn(),
20
+ mockTraceEvent: vi.fn(),
21
+ mockTraceClear: vi.fn(),
22
+ mockDiscard: vi.fn(),
23
+ mockBffLogout: vi.fn(),
24
+ mockPost: vi.fn(),
25
+ }));
26
+
27
+ vi.mock('../store', () => ({
28
+ useAuthStore: {
29
+ getState: () => ({
30
+ clearAuth: mockClearAuth,
31
+ user: null,
32
+ isAuthenticated: false,
33
+ currentTenantId: null,
34
+ tenants: [],
35
+ permissions: [],
36
+ setTokens: vi.fn(),
37
+ setTenants: vi.fn(),
38
+ setPermissions: vi.fn(),
39
+ setCurrentTenant: vi.fn(),
40
+ }),
41
+ },
42
+ getAccessToken: () => mockCtx.accessToken,
43
+ getRefreshToken: () => mockCtx.refreshToken,
44
+ getCurrentRole: () => null,
45
+ isTokenExpired: () => false,
46
+ loginWithTokens: vi.fn(),
47
+ }));
48
+
49
+ vi.mock('../roles', () => ({
50
+ buildLoginUrl: vi.fn(() => 'https://auth.example.com/login'),
51
+ isAuthLoginSurface: vi.fn(() => false),
52
+ }));
53
+
54
+ vi.mock('../auth-trace', () => ({
55
+ traceClear: mockTraceClear,
56
+ traceEvent: mockTraceEvent,
57
+ traceRedirect: vi.fn(),
58
+ }));
59
+
60
+ vi.mock('../oauth-client-id-store', () => ({
61
+ resolveClientIdForSession: () => mockCtx.clientId,
62
+ }));
63
+
64
+ vi.mock('../oauth-login', () => ({
65
+ discardPendingOAuthLogin: mockDiscard,
66
+ }));
67
+
68
+ vi.mock('../api/bff-client', () => ({
69
+ bffLogout: mockBffLogout,
70
+ }));
71
+
72
+ vi.mock('../../config', () => ({
73
+ getApiBaseUrl: () => '/bff',
74
+ }));
75
+
76
+ vi.mock('axios', () => ({
77
+ default: { post: mockPost },
78
+ }));
79
+
80
+ import { AuthService } from '../service';
81
+
82
+ const JWT_RT = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1MSJ9.sig';
83
+
84
+ beforeEach(() => {
85
+ vi.clearAllMocks();
86
+ mockCtx.accessToken = null;
87
+ mockCtx.refreshToken = null;
88
+ mockCtx.clientId = '';
89
+ mockPost.mockResolvedValue({ data: {} });
90
+ localStorage.clear();
91
+ });
92
+
93
+ describe('AuthService.logout 分链吊销(AUTH-15)', () => {
94
+ it('OAuth 链(不透明 rt + client_id)→ oauth revoke ×2 带 client_id,不打 identity logout', async () => {
95
+ mockCtx.accessToken = 'at-opaque';
96
+ mockCtx.refreshToken = 'rt-opaque';
97
+ mockCtx.clientId = 'cid-1';
98
+
99
+ await AuthService.logout();
100
+
101
+ expect(mockPost).toHaveBeenCalledTimes(2);
102
+ const [url0, body0, cfg0] = mockPost.mock.calls[0];
103
+ expect(url0).toBe('/bff/oauth/api/v1/oauth/revoke');
104
+ const p0 = new URLSearchParams(body0 as string);
105
+ expect(p0.get('token')).toBe('at-opaque');
106
+ expect(p0.get('token_type_hint')).toBe('access_token');
107
+ expect(p0.get('client_id')).toBe('cid-1');
108
+ expect((cfg0 as any).headers['Content-Type']).toBe('application/x-www-form-urlencoded');
109
+
110
+ const [url1, body1] = mockPost.mock.calls[1];
111
+ expect(url1).toBe('/bff/oauth/api/v1/oauth/revoke');
112
+ const p1 = new URLSearchParams(body1 as string);
113
+ expect(p1.get('token')).toBe('rt-opaque');
114
+ expect(p1.get('token_type_hint')).toBe('refresh_token');
115
+ expect(p1.get('client_id')).toBe('cid-1');
116
+
117
+ expect(mockPost.mock.calls.some(([u]) => String(u).includes('identity'))).toBe(false);
118
+ expect(mockTraceEvent).toHaveBeenCalledWith('logout-revoke', {
119
+ reason: 'oauth-access_token',
120
+ to: 'ok',
121
+ });
122
+ expect(mockTraceEvent).toHaveBeenCalledWith('logout-revoke', {
123
+ reason: 'oauth-refresh_token',
124
+ to: 'ok',
125
+ });
126
+ });
127
+
128
+ it('OAuth 链无 AT(仅 rt)→ 只 revoke refresh_token', async () => {
129
+ mockCtx.refreshToken = 'rt-opaque';
130
+ mockCtx.clientId = 'cid-1';
131
+
132
+ await AuthService.logout();
133
+
134
+ expect(mockPost).toHaveBeenCalledTimes(1);
135
+ const p = new URLSearchParams(mockPost.mock.calls[0][1] as string);
136
+ expect(p.get('token')).toBe('rt-opaque');
137
+ expect(p.get('token_type_hint')).toBe('refresh_token');
138
+ });
139
+
140
+ it('identity 链(JWT rt,无 client_id)→ identity /auth/logout 携 Bearer AT,不打 oauth revoke', async () => {
141
+ mockCtx.accessToken = 'at-jwt';
142
+ mockCtx.refreshToken = JWT_RT;
143
+
144
+ await AuthService.logout();
145
+
146
+ expect(mockPost).toHaveBeenCalledTimes(1);
147
+ const [url, body, cfg] = mockPost.mock.calls[0];
148
+ expect(url).toBe('/bff/identity/api/v1/auth/logout');
149
+ expect(body).toBeNull();
150
+ expect((cfg as any).headers.Authorization).toBe('Bearer at-jwt');
151
+ expect(mockTraceEvent).toHaveBeenCalledWith('logout-revoke', { reason: 'identity', to: 'ok' });
152
+ });
153
+
154
+ it('JWT rt + 持久 client_id 仍走 identity(镜像 refreshToken 分链判据)', async () => {
155
+ mockCtx.accessToken = 'at-jwt';
156
+ mockCtx.refreshToken = JWT_RT;
157
+ mockCtx.clientId = 'cid-1';
158
+
159
+ await AuthService.logout();
160
+
161
+ expect(mockPost).toHaveBeenCalledTimes(1);
162
+ expect(mockPost.mock.calls[0][0]).toBe('/bff/identity/api/v1/auth/logout');
163
+ });
164
+
165
+ it('仅剩无凭据 rt(identity JWT rt 且无 AT)→ 明示跳过并留痕', async () => {
166
+ mockCtx.refreshToken = JWT_RT;
167
+
168
+ await AuthService.logout();
169
+
170
+ expect(mockPost).not.toHaveBeenCalled();
171
+ expect(mockTraceEvent).toHaveBeenCalledWith('logout-revoke-skipped', {
172
+ reason: 'no-credential',
173
+ to: 'ok',
174
+ });
175
+ });
176
+
177
+ it('AUTH-08 登出卫生:清 localStorage 四键 + OAuth 预置物 + trace 缓冲 + 内存态', async () => {
178
+ mockCtx.accessToken = 'at-jwt';
179
+ mockCtx.refreshToken = JWT_RT;
180
+ localStorage.setItem('autional-auth-v1', 'x');
181
+ localStorage.setItem('access_token', 'x');
182
+ localStorage.setItem('refresh_token', 'x');
183
+ localStorage.setItem('__oauth_bridge_token', 'x');
184
+
185
+ await AuthService.logout();
186
+
187
+ expect(localStorage.getItem('autional-auth-v1')).toBeNull();
188
+ expect(localStorage.getItem('access_token')).toBeNull();
189
+ expect(localStorage.getItem('refresh_token')).toBeNull();
190
+ expect(localStorage.getItem('__oauth_bridge_token')).toBeNull();
191
+ expect(mockClearAuth).toHaveBeenCalledTimes(1);
192
+ expect(mockDiscard).toHaveBeenCalledTimes(1);
193
+ expect(mockTraceClear).toHaveBeenCalledTimes(1);
194
+ });
195
+
196
+ it('吊销失败(4xx):trace 留痕 failed(带 HTTP status)且 logout 仍 resolve', async () => {
197
+ mockCtx.accessToken = 'at-jwt';
198
+ mockCtx.refreshToken = JWT_RT;
199
+ mockPost.mockRejectedValueOnce({ response: { status: 401 } });
200
+
201
+ await expect(AuthService.logout()).resolves.toBeUndefined();
202
+ expect(mockTraceEvent).toHaveBeenCalledWith('logout-revoke-failed', {
203
+ reason: 'identity',
204
+ to: '401',
205
+ });
206
+ });
207
+
208
+ it('吊销失败(网络异常,无 response):to=network-error 且 logout 仍 resolve', async () => {
209
+ mockCtx.accessToken = 'at-jwt';
210
+ mockCtx.refreshToken = JWT_RT;
211
+ mockPost.mockRejectedValueOnce(new Error('ECONNREFUSED'));
212
+
213
+ await expect(AuthService.logout()).resolves.toBeUndefined();
214
+ expect(mockTraceEvent).toHaveBeenCalledWith('logout-revoke-failed', {
215
+ reason: 'identity',
216
+ to: 'network-error',
217
+ });
218
+ });
219
+ });
@@ -0,0 +1,69 @@
1
+ // @vitest-environment jsdom
2
+ // AUTH-46② 回归锁:handleOAuthCallback 失败路径以 OAuthCallbackError 携带稳定 code
3
+ // (oauth.provider_error / oauth.missing_code / oauth.state_mismatch / oauth.pkce_cleared),
4
+ // 供回调页按 code 映射本地化文案——此前英文裸句("Missing authorization code" 等)直接落用户屏。
5
+
6
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
7
+
8
+ const { mockPost } = vi.hoisted(() => ({ mockPost: vi.fn() }));
9
+
10
+ vi.mock('../../api/client', () => ({ apiClient: { post: mockPost, get: vi.fn() } }));
11
+ vi.mock('../store', () => ({ loginWithTokens: vi.fn() }));
12
+ vi.mock('../service', () => ({ AuthService: { updateCurrentTenant: vi.fn() } }));
13
+ vi.mock('../oauth-client-id-store', () => ({ persistOAuthClientId: vi.fn() }));
14
+ vi.mock('../auth-trace', () => ({ traceRedirect: vi.fn() }));
15
+ vi.mock('../../config', () => ({ getPortalUrl: () => 'https://auth.example.com' }));
16
+
17
+ import { handleOAuthCallback, OAuthCallbackError } from '../oauth-login';
18
+
19
+ async function codeOf(promise: Promise<unknown>): Promise<string> {
20
+ try {
21
+ await promise;
22
+ throw new Error('expected rejection');
23
+ } catch (e) {
24
+ expect(e).toBeInstanceOf(OAuthCallbackError);
25
+ return (e as OAuthCallbackError).code;
26
+ }
27
+ }
28
+
29
+ beforeEach(() => {
30
+ vi.clearAllMocks();
31
+ sessionStorage.clear();
32
+ });
33
+
34
+ describe('handleOAuthCallback 错误 code(AUTH-46②)', () => {
35
+ it('provider error 参数 → oauth.provider_error(description 作 message 保留)', async () => {
36
+ window.history.replaceState({}, '', '/?error=access_denied&error_description=user+denied');
37
+ const code = await codeOf(handleOAuthCallback());
38
+ expect(code).toBe('oauth.provider_error');
39
+ });
40
+
41
+ it('无 code → oauth.missing_code', async () => {
42
+ window.history.replaceState({}, '', '/?state=%7B%22csrf%22%3A%22a%22%7D');
43
+ const code = await codeOf(handleOAuthCallback());
44
+ expect(code).toBe('oauth.missing_code');
45
+ });
46
+
47
+ it('state csrf 不匹配 → oauth.state_mismatch', async () => {
48
+ sessionStorage.setItem('oauth_state', 'csrf-A');
49
+ sessionStorage.setItem('oauth_pkce_verifier', 'v-1');
50
+ window.history.replaceState(
51
+ {},
52
+ '',
53
+ '/?code=c1&state=' + encodeURIComponent(JSON.stringify({ csrf: 'csrf-B' })),
54
+ );
55
+ const code = await codeOf(handleOAuthCallback());
56
+ expect(code).toBe('oauth.state_mismatch');
57
+ });
58
+
59
+ it('state 通过但 PKCE verifier 缺失(刷新清存储)→ oauth.pkce_cleared', async () => {
60
+ sessionStorage.setItem('oauth_state', 'csrf-A');
61
+ window.history.replaceState(
62
+ {},
63
+ '',
64
+ '/?code=c1&state=' + encodeURIComponent(JSON.stringify({ csrf: 'csrf-A' })),
65
+ );
66
+ const code = await codeOf(handleOAuthCallback());
67
+ expect(code).toBe('oauth.pkce_cleared');
68
+ });
69
+ });
@@ -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,118 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+
4
+ // ============================================================
5
+ // AUTH-03/08:initiateOAuthLogin 的「停留」语义与防重入窗口
6
+ // - onStay 转发给调用方(RequireAuth 据此渲染恢复态)并清零本次预置物
7
+ // - 「停留」取消待重试定时器:窗口过期后不再自动把用户弹走
8
+ // - force=true 跳过 10s 防重入窗口(恢复态「前往登录」按钮用)
9
+ // ============================================================
10
+
11
+ const { mockTrace, mockPersistCid, mockGeneratePKCE } = vi.hoisted(() => ({
12
+ mockTrace: vi.fn(),
13
+ mockPersistCid: vi.fn(),
14
+ mockGeneratePKCE: vi.fn(() => Promise.resolve({ verifier: 'v-1', challenge: 'c-1' })),
15
+ }));
16
+
17
+ vi.mock('../../api/client', () => ({ apiClient: { post: vi.fn(), get: vi.fn() } }));
18
+ vi.mock('../store', () => ({ loginWithTokens: vi.fn() }));
19
+ vi.mock('../service', () => ({ AuthService: { updateCurrentTenant: vi.fn() } }));
20
+ vi.mock('../oauth-client-id-store', () => ({ persistOAuthClientId: mockPersistCid }));
21
+ vi.mock('../auth-trace', () => ({ traceRedirect: mockTrace }));
22
+ vi.mock('../pkce', () => ({ generatePKCE: () => mockGeneratePKCE() }));
23
+ vi.mock('../../config', () => ({ getPortalUrl: () => 'https://auth.example.com' }));
24
+
25
+ import { initiateOAuthLogin, discardPendingOAuthLogin } from '../oauth-login';
26
+
27
+ beforeEach(() => {
28
+ vi.clearAllMocks();
29
+ sessionStorage.clear();
30
+ });
31
+
32
+ afterEach(() => {
33
+ vi.useRealTimers();
34
+ });
35
+
36
+ describe('initiateOAuthLogin「停留」与防重入(AUTH-03/08)', () => {
37
+ it('「停留」→ onStay 转发 + 本次预置物全清(state/verifier/cid/init_ts)', async () => {
38
+ const onStay = vi.fn();
39
+ await initiateOAuthLogin('cid-1', 'https://user.example.com/x', { onStay });
40
+
41
+ expect(mockTrace).toHaveBeenCalledTimes(1);
42
+ const opts = mockTrace.mock.calls[0][1];
43
+ expect(opts.kind).toBe('interstitial');
44
+ expect(typeof opts.onStay).toBe('function');
45
+ // 发起时预置物就位
46
+ expect(sessionStorage.getItem('oauth_state')).toBeTruthy();
47
+ expect(sessionStorage.getItem('oauth_pkce_verifier')).toBe('v-1');
48
+ expect(sessionStorage.getItem('oauth_client_id')).toBe('cid-1');
49
+ expect(sessionStorage.getItem('oauth_init_ts')).toBeTruthy();
50
+
51
+ opts.onStay();
52
+
53
+ expect(onStay).toHaveBeenCalledTimes(1);
54
+ expect(sessionStorage.getItem('oauth_state')).toBeNull();
55
+ expect(sessionStorage.getItem('oauth_pkce_verifier')).toBeNull();
56
+ expect(sessionStorage.getItem('oauth_client_id')).toBeNull();
57
+ expect(sessionStorage.getItem('oauth_init_ts')).toBeNull();
58
+ });
59
+
60
+ it('10s 防重入窗口内默认被抑制(无跳转);force=true 直通', async () => {
61
+ sessionStorage.setItem('oauth_init_ts', String(Date.now()));
62
+
63
+ await initiateOAuthLogin('cid-1');
64
+ expect(mockTrace).not.toHaveBeenCalled();
65
+
66
+ await initiateOAuthLogin('cid-1', undefined, { force: true });
67
+ expect(mockTrace).toHaveBeenCalledTimes(1);
68
+ });
69
+
70
+ it('「停留」取消待重试定时器:窗口过期后不再自动发起', async () => {
71
+ vi.useFakeTimers();
72
+ sessionStorage.setItem('oauth_init_ts', String(Date.now()));
73
+
74
+ // 窗口内首次调用被抑制,安排窗口过期后的自动重试
75
+ await initiateOAuthLogin('cid-1');
76
+ expect(mockTrace).not.toHaveBeenCalled();
77
+
78
+ // 窗口过期 → 自动重试真正发起,拿到本轮的 onStay
79
+ await vi.advanceTimersByTimeAsync(10200);
80
+ expect(mockTrace).toHaveBeenCalledTimes(1);
81
+ const opts = mockTrace.mock.calls[0][1];
82
+ opts.onStay();
83
+
84
+ // 预置物清零 + 定时器已取消:再次调用立即发起(不被窗口吞、无遗留重试)
85
+ mockTrace.mockClear();
86
+ await initiateOAuthLogin('cid-1');
87
+ expect(mockTrace).toHaveBeenCalledTimes(1);
88
+ await vi.advanceTimersByTimeAsync(15000);
89
+ expect(mockTrace).toHaveBeenCalledTimes(1);
90
+ });
91
+ });
92
+
93
+ describe('discardPendingOAuthLogin(AUTH-08 会话卫生,登出/停留共用)', () => {
94
+ it('清 sessionStorage 四键 + 取消待重试定时器', async () => {
95
+ vi.useFakeTimers();
96
+ sessionStorage.setItem('oauth_init_ts', String(Date.now()));
97
+
98
+ // 窗口内首次调用被抑制 → 安排窗口过期后的自动重试
99
+ await initiateOAuthLogin('cid-1');
100
+ expect(mockTrace).not.toHaveBeenCalled();
101
+
102
+ // 模拟发起中途残留的预置物
103
+ sessionStorage.setItem('oauth_state', 's-1');
104
+ sessionStorage.setItem('oauth_pkce_verifier', 'v-1');
105
+ sessionStorage.setItem('oauth_client_id', 'cid-1');
106
+
107
+ discardPendingOAuthLogin();
108
+
109
+ expect(sessionStorage.getItem('oauth_state')).toBeNull();
110
+ expect(sessionStorage.getItem('oauth_pkce_verifier')).toBeNull();
111
+ expect(sessionStorage.getItem('oauth_client_id')).toBeNull();
112
+ expect(sessionStorage.getItem('oauth_init_ts')).toBeNull();
113
+
114
+ // 重试定时器已取消:窗口过期后不再自动发起
115
+ await vi.advanceTimersByTimeAsync(15000);
116
+ expect(mockTrace).not.toHaveBeenCalled();
117
+ });
118
+ });