@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.
- package/LICENSE +661 -0
- package/package.json +49 -3
- package/src/api/__tests__/contract.test.ts +172 -0
- package/src/api/__tests__/entry-plane.test.ts +119 -0
- package/src/api/__tests__/namespace.test.ts +27 -0
- package/src/api/__tests__/preflight-refresh.test.ts +89 -0
- package/src/api/bff-client.ts +94 -0
- package/src/api/client.ts +191 -0
- package/src/api/entry-plane.ts +61 -0
- package/src/api/namespace.ts +14 -0
- package/src/api/trace.ts +21 -0
- package/src/auth/__tests__/ac007-role-source.test.ts +124 -0
- package/src/auth/__tests__/auth-machine.test.ts +157 -0
- package/src/auth/__tests__/auth-trace.test.ts +321 -0
- package/src/auth/__tests__/build-login-url.test.ts +259 -0
- package/src/auth/__tests__/get-current-role.test.ts +61 -0
- package/src/auth/__tests__/jwt-payload.test.ts +49 -0
- package/src/auth/__tests__/logout-redirect.test.ts +44 -0
- package/src/auth/__tests__/logout-revoke.test.ts +219 -0
- package/src/auth/__tests__/oauth-callback-error-codes.test.ts +69 -0
- package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
- package/src/auth/__tests__/oauth-initiate-stay.test.ts +118 -0
- package/src/auth/__tests__/oauth-login-user-resolution.test.ts +185 -0
- package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
- package/src/auth/__tests__/session-client-id.test.ts +147 -0
- package/src/auth/__tests__/session-terminal-redirect.test.ts +138 -0
- package/src/auth/__tests__/slug-from-url.test.ts +112 -0
- package/src/auth/auth-machine.ts +151 -0
- package/src/auth/auth-queries.ts +118 -0
- package/src/auth/auth-trace.ts +511 -0
- package/src/auth/build-tenant-urls.ts +48 -0
- package/src/auth/jwt-payload.ts +21 -0
- package/src/auth/oauth-client-from-slug.ts +119 -0
- package/src/auth/oauth-client-id-store.ts +41 -0
- package/src/auth/oauth-login.ts +388 -0
- package/src/auth/pkce.ts +141 -0
- package/src/auth/portal-config.ts +68 -0
- package/src/auth/roles.ts +120 -0
- package/src/auth/service.ts +433 -0
- package/src/auth/slug-from-url.ts +92 -0
- package/src/auth/store.ts +227 -0
- package/src/auth/tenant-route-middleware.ts +113 -0
- package/src/auth/tenant-slug-context.tsx +9 -0
- package/src/branding/BrandingInitializer.tsx +60 -0
- package/src/branding/__tests__/brand-vars.test.ts +126 -0
- package/src/branding/brand-color.ts +193 -0
- package/src/branding/branding-cache.ts +71 -0
- package/src/branding/extract-branding.ts +27 -0
- package/src/branding/index.ts +15 -0
- package/src/branding/tenant-store.ts +13 -0
- package/src/branding/types.ts +14 -0
- package/src/branding/useBranding.ts +101 -0
- package/src/components/AuditStatsOnly.tsx +65 -0
- package/src/components/OAuthCallbackPage.tsx +83 -0
- package/src/components/PortalGuard.tsx +105 -0
- package/src/components/RequireAuth.tsx +220 -0
- package/src/components/TenantIndexGuard.tsx +131 -0
- package/src/components/TenantRootRedirect.tsx +49 -0
- package/src/components/__tests__/RequireAuth.adversarial.test.tsx +165 -0
- package/src/components/__tests__/RequireAuth.stay.test.tsx +131 -0
- package/src/components/__tests__/RequireAuth.test.tsx +296 -0
- package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
- package/src/components/__tests__/TenantRootRedirect.test.tsx +180 -0
- package/src/config/__tests__/portal-resolver.test.ts +302 -0
- package/src/config/__tests__/valid-redirect.test.ts +47 -0
- package/src/config/constants.ts +27 -0
- package/src/config/index.ts +426 -0
- package/src/config/resolve-basename.ts +57 -0
- package/src/constants/api-paths.ts +450 -0
- package/src/generated/api.ts +13012 -0
- package/src/generated/queries.ts +12984 -0
- package/src/generated/types.ts +13982 -0
- package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
- package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
- package/src/hooks/__tests__/useCurrentTenantIdOr.test.tsx +38 -0
- package/src/hooks/__tests__/useLogout.test.tsx +61 -0
- package/src/hooks/__tests__/usePermission.map.test.tsx +156 -0
- package/src/hooks/__tests__/usePortalCatalog.test.tsx +301 -0
- package/src/hooks/__tests__/useSodMode.test.tsx +205 -0
- package/src/hooks/useAuth.ts +239 -0
- package/src/hooks/useBootstrap.ts +74 -0
- package/src/hooks/useCurrentRole.ts +18 -0
- package/src/hooks/useCurrentTenantIdOr.ts +29 -0
- package/src/hooks/useLogout.ts +42 -0
- package/src/hooks/usePermission.ts +128 -0
- package/src/hooks/usePortalCatalog.ts +199 -0
- package/src/hooks/useSodMode.ts +81 -0
- package/src/index.ts +247 -0
- package/src/lib/__tests__/push.test.ts +30 -0
- package/src/lib/push.ts +44 -0
- package/src/seo/dom-utils.ts +94 -0
- package/src/seo/index.ts +6 -0
- package/src/seo/jsonld.ts +29 -0
- package/src/seo/types.ts +29 -0
- package/src/seo/usePageMeta.ts +17 -0
- package/src/seo/usePageTitle.ts +14 -0
- package/src/seo/useSEO.ts +80 -0
- package/src/types/index.ts +190 -0
- package/src/utils/__tests__/case.test.ts +94 -0
- package/src/utils/__tests__/error.test.ts +72 -0
- package/src/utils/__tests__/mock-test.ts +15 -0
- package/src/utils/__tests__/page.test.ts +54 -0
- package/src/utils/__tests__/query-state.test.ts +120 -0
- package/src/utils/browser.ts +15 -0
- package/src/utils/case.ts +59 -0
- package/src/utils/error.ts +35 -0
- package/src/utils/format.ts +63 -0
- package/src/utils/page.ts +57 -0
- package/src/utils/password-transmission.test.ts +145 -0
- package/src/utils/password-transmission.ts +253 -0
- package/src/utils/query-state.ts +85 -0
- package/src/utils/response.ts +71 -0
- package/src/utils/vite.ts +4 -0
- package/src/vite-env.d.ts +19 -0
- package/README.md +0 -3
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// ============================================================
|
|
5
|
+
// authTrace 三层回归锁(#48)
|
|
6
|
+
// ① interstitial:被动跳转 → 提示条(可停留),延迟后 replace
|
|
7
|
+
// ② debug=auth → 确认页(不自动跳,手动继续/留)
|
|
8
|
+
// ③ 结构化日志:环形缓冲 / 凭据剥离 / ?rt= 跨站面包屑 / dump()
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
const replaceSpy = vi.fn();
|
|
12
|
+
let assignCalls: string[] = [];
|
|
13
|
+
let currentHref = '';
|
|
14
|
+
const originalLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location');
|
|
15
|
+
|
|
16
|
+
// 只替换 window.location(不动 window 本体 —— stubGlobal 会连 localStorage/sessionStorage
|
|
17
|
+
// 一起遮蔽)。字段给纯值 + href getter/setter,绕开 jsdom Location brand check。
|
|
18
|
+
function mockWindowLocation(pathname: string, search = ''): void {
|
|
19
|
+
currentHref = `https://user.autional.local${pathname}${search}`;
|
|
20
|
+
Object.defineProperty(window, 'location', {
|
|
21
|
+
configurable: true,
|
|
22
|
+
value: {
|
|
23
|
+
pathname,
|
|
24
|
+
search,
|
|
25
|
+
origin: 'https://user.autional.local',
|
|
26
|
+
hostname: 'user.autional.local',
|
|
27
|
+
protocol: 'https:',
|
|
28
|
+
replace: replaceSpy,
|
|
29
|
+
get href() {
|
|
30
|
+
return currentHref;
|
|
31
|
+
},
|
|
32
|
+
set href(v: string) {
|
|
33
|
+
assignCalls.push(v);
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 每例取全新模块实例:清 initialized / pending / buffer 模块态。 */
|
|
40
|
+
async function freshTrace() {
|
|
41
|
+
vi.resetModules();
|
|
42
|
+
return await import('../auth-trace');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function notice(): HTMLElement | null {
|
|
46
|
+
return document.getElementById('auth-trace-notice');
|
|
47
|
+
}
|
|
48
|
+
function confirm(): HTMLElement | null {
|
|
49
|
+
return document.getElementById('auth-trace-confirm');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
vi.clearAllMocks();
|
|
54
|
+
assignCalls = [];
|
|
55
|
+
sessionStorage.clear();
|
|
56
|
+
document.body.innerHTML = '';
|
|
57
|
+
document.documentElement.lang = 'zh-CN';
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
if (originalLocationDescriptor) {
|
|
62
|
+
Object.defineProperty(window, 'location', originalLocationDescriptor);
|
|
63
|
+
}
|
|
64
|
+
document.body.innerHTML = '';
|
|
65
|
+
vi.useRealTimers();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('层 ③ 结构化日志与缓冲', () => {
|
|
69
|
+
it('traceEvent 写入环形缓冲,50 条封顶(丢最旧)', async () => {
|
|
70
|
+
mockWindowLocation('/demo/');
|
|
71
|
+
const { traceEvent, traceDump } = await freshTrace();
|
|
72
|
+
for (let i = 0; i < 55; i++) traceEvent('evt', { reason: `r${i}` });
|
|
73
|
+
const entries = (window as any).__authTrace.entries() as any[];
|
|
74
|
+
expect(entries).toHaveLength(50);
|
|
75
|
+
expect(entries[0].reason).toBe('r5');
|
|
76
|
+
expect(entries[49].reason).toBe('r54');
|
|
77
|
+
expect(traceDump()).toContain('r54');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('缓冲中的 URL 剥除凭据类 query(token/code/state 不入 trace)', async () => {
|
|
81
|
+
mockWindowLocation('/demo/dashboard');
|
|
82
|
+
const { traceRedirect } = await freshTrace();
|
|
83
|
+
traceRedirect(
|
|
84
|
+
'https://auth.autional.local/demo/login?redirect=x&access_token=SECRET&code=ABC123&state=st&ok=1',
|
|
85
|
+
{ reason: 'unauthenticated', kind: 'funnel' },
|
|
86
|
+
);
|
|
87
|
+
const entries = (window as any).__authTrace.entries() as any[];
|
|
88
|
+
const to = entries[entries.length - 1].to as string;
|
|
89
|
+
expect(to).not.toContain('SECRET');
|
|
90
|
+
expect(to).not.toContain('ABC123');
|
|
91
|
+
expect(to).not.toContain('state=');
|
|
92
|
+
expect(to).toContain('ok=1');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('dump() 含环境摘要与条目', async () => {
|
|
96
|
+
mockWindowLocation('/demo/dashboard');
|
|
97
|
+
const { traceEvent, traceDump } = await freshTrace();
|
|
98
|
+
traceEvent('error-exit', { reason: 'session_expired' });
|
|
99
|
+
const dump = traceDump();
|
|
100
|
+
expect(dump).toContain('authTrace v1 @user');
|
|
101
|
+
expect(dump).toContain('error-exit');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('?rt= 跨站面包屑:读取接续为 handoff 条目', async () => {
|
|
105
|
+
mockWindowLocation('/demo/login', '?rt=auth.unauthenticated.abc123&redirect=x');
|
|
106
|
+
const { traceDump } = await freshTrace();
|
|
107
|
+
const dump = traceDump();
|
|
108
|
+
expect(dump).toContain('handoff');
|
|
109
|
+
expect(dump).toContain('auth@');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('层 ① interstitial 提示与停留', () => {
|
|
114
|
+
it('被动跳转:先出提示条,延迟后 replace(URL 带 rt= 面包屑)', async () => {
|
|
115
|
+
mockWindowLocation('/demo/dashboard');
|
|
116
|
+
const { traceRedirect } = await freshTrace();
|
|
117
|
+
vi.useFakeTimers();
|
|
118
|
+
|
|
119
|
+
traceRedirect('https://auth.autional.local/demo/login?redirect=x', {
|
|
120
|
+
reason: 'session-expired',
|
|
121
|
+
kind: 'interstitial',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
125
|
+
expect(notice()).not.toBeNull();
|
|
126
|
+
expect(notice()!.textContent).toContain('会话已过期');
|
|
127
|
+
|
|
128
|
+
vi.advanceTimersByTime(1600);
|
|
129
|
+
expect(notice()).toBeNull();
|
|
130
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
131
|
+
expect(String(replaceSpy.mock.calls[0][0])).toContain('rt=user.session-expired.');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('点「停留」:取消跳转 + pending 复位(后续 funnel 照常执行)', async () => {
|
|
135
|
+
mockWindowLocation('/demo/dashboard');
|
|
136
|
+
const { traceRedirect } = await freshTrace();
|
|
137
|
+
vi.useFakeTimers();
|
|
138
|
+
|
|
139
|
+
traceRedirect('https://auth.autional.local/demo/login?redirect=x', {
|
|
140
|
+
reason: 'session-expired',
|
|
141
|
+
kind: 'interstitial',
|
|
142
|
+
});
|
|
143
|
+
const stayBtn = Array.from(notice()!.querySelectorAll('button')).find((b) =>
|
|
144
|
+
b.textContent!.includes('停留'),
|
|
145
|
+
)!;
|
|
146
|
+
stayBtn.click();
|
|
147
|
+
|
|
148
|
+
expect(notice()).toBeNull();
|
|
149
|
+
vi.advanceTimersByTime(5000);
|
|
150
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
151
|
+
|
|
152
|
+
// pending 已复位:funnel 直跳不受阻
|
|
153
|
+
traceRedirect('https://brand.autional.local/?redirect=x', { reason: 'funnel-slug', kind: 'funnel' });
|
|
154
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('AUTH-03 点「停留」:onStay 回调触发(调用方据此恢复 UI)', async () => {
|
|
158
|
+
mockWindowLocation('/demo/dashboard');
|
|
159
|
+
const { traceRedirect } = await freshTrace();
|
|
160
|
+
vi.useFakeTimers();
|
|
161
|
+
const onStay = vi.fn();
|
|
162
|
+
|
|
163
|
+
traceRedirect('https://auth.autional.local/demo/login?redirect=x', {
|
|
164
|
+
reason: 'unauthenticated',
|
|
165
|
+
kind: 'interstitial',
|
|
166
|
+
onStay,
|
|
167
|
+
});
|
|
168
|
+
const stayBtn = Array.from(notice()!.querySelectorAll('button')).find((b) =>
|
|
169
|
+
b.textContent!.includes('停留'),
|
|
170
|
+
)!;
|
|
171
|
+
stayBtn.click();
|
|
172
|
+
|
|
173
|
+
expect(onStay).toHaveBeenCalledTimes(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('AUTH-03 onStay 回调抛错:不冒泡打断停留处理(pending 仍复位)', async () => {
|
|
177
|
+
mockWindowLocation('/demo/dashboard');
|
|
178
|
+
const { traceRedirect } = await freshTrace();
|
|
179
|
+
vi.useFakeTimers();
|
|
180
|
+
|
|
181
|
+
traceRedirect('https://auth.autional.local/demo/login?redirect=x', {
|
|
182
|
+
reason: 'unauthenticated',
|
|
183
|
+
kind: 'interstitial',
|
|
184
|
+
onStay: () => {
|
|
185
|
+
throw new Error('boom');
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
const stayBtn = Array.from(notice()!.querySelectorAll('button')).find((b) =>
|
|
189
|
+
b.textContent!.includes('停留'),
|
|
190
|
+
)!;
|
|
191
|
+
expect(() => stayBtn.click()).not.toThrow();
|
|
192
|
+
|
|
193
|
+
vi.advanceTimersByTime(5000);
|
|
194
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
195
|
+
// pending 已复位:funnel 直跳不受阻
|
|
196
|
+
traceRedirect('https://brand.autional.local/?redirect=x', { reason: 'funnel-slug', kind: 'funnel' });
|
|
197
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('pending 抑制:提示条未决时再来一次 interstitial 被记为 suppressed', async () => {
|
|
201
|
+
mockWindowLocation('/demo/dashboard');
|
|
202
|
+
const { traceRedirect } = await freshTrace();
|
|
203
|
+
vi.useFakeTimers();
|
|
204
|
+
|
|
205
|
+
traceRedirect('https://auth.autional.local/demo/login', { reason: 'unauthenticated', kind: 'interstitial' });
|
|
206
|
+
traceRedirect('https://auth.autional.local/other/login', { reason: 'unauthenticated', kind: 'interstitial' });
|
|
207
|
+
|
|
208
|
+
const entries = (window as any).__authTrace.entries() as any[];
|
|
209
|
+
expect(entries.filter((e) => e.ev === 'suppressed')).toHaveLength(1);
|
|
210
|
+
vi.advanceTimersByTime(1600);
|
|
211
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
212
|
+
expect(String(replaceSpy.mock.calls[0][0])).toContain('/demo/login');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('funnel(缺省 kind):静默直跳,不出现提示条', async () => {
|
|
216
|
+
mockWindowLocation('/');
|
|
217
|
+
const { traceRedirect } = await freshTrace();
|
|
218
|
+
traceRedirect('https://brand.autional.local/?redirect=x', { reason: 'funnel-brand' });
|
|
219
|
+
expect(notice()).toBeNull();
|
|
220
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('层 ② debug 确认页', () => {
|
|
225
|
+
it('?debug=auth:不自动跳,确认页出现;点「继续前往」才 replace', async () => {
|
|
226
|
+
mockWindowLocation('/demo/dashboard', '?debug=auth');
|
|
227
|
+
const { traceRedirect } = await freshTrace();
|
|
228
|
+
traceRedirect('https://auth.autional.local/demo/login?redirect=x', {
|
|
229
|
+
reason: 'session-expired',
|
|
230
|
+
kind: 'interstitial',
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
234
|
+
expect(confirm()).not.toBeNull();
|
|
235
|
+
expect(confirm()!.textContent).toContain('整页跳转确认');
|
|
236
|
+
|
|
237
|
+
const goBtn = Array.from(confirm()!.querySelectorAll('button')).find((b) =>
|
|
238
|
+
b.textContent!.includes('继续前往'),
|
|
239
|
+
)!;
|
|
240
|
+
goBtn.click();
|
|
241
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
242
|
+
expect(String(replaceSpy.mock.calls[0][0])).toContain('debug=auth');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('确认页点「留在本页」:不跳 + pending 复位 + onStay 回调触发', async () => {
|
|
246
|
+
mockWindowLocation('/demo/dashboard', '?debug=auth');
|
|
247
|
+
const { traceRedirect } = await freshTrace();
|
|
248
|
+
const onStay = vi.fn();
|
|
249
|
+
traceRedirect('https://auth.autional.local/demo/login', {
|
|
250
|
+
reason: 'unauthenticated',
|
|
251
|
+
kind: 'funnel',
|
|
252
|
+
onStay,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const stayBtn = Array.from(confirm()!.querySelectorAll('button')).find((b) =>
|
|
256
|
+
b.textContent!.includes('留在本页'),
|
|
257
|
+
)!;
|
|
258
|
+
stayBtn.click();
|
|
259
|
+
expect(confirm()).toBeNull();
|
|
260
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
261
|
+
expect(onStay).toHaveBeenCalledTimes(1);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('debug 标志经 sessionStorage 保持并随跳转透传(无需每次带 ?debug=auth)', async () => {
|
|
265
|
+
mockWindowLocation('/demo/');
|
|
266
|
+
const { traceRedirect } = await freshTrace();
|
|
267
|
+
(window as any).__authTrace.on();
|
|
268
|
+
|
|
269
|
+
traceRedirect('https://auth.autional.local/demo/login', { reason: 'unauthenticated' });
|
|
270
|
+
expect(confirm()).not.toBeNull();
|
|
271
|
+
const goBtn = Array.from(confirm()!.querySelectorAll('button')).find((b) =>
|
|
272
|
+
b.textContent!.includes('继续前往'),
|
|
273
|
+
)!;
|
|
274
|
+
goBtn.click();
|
|
275
|
+
expect(String(replaceSpy.mock.calls[0][0])).toContain('debug=auth');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('assign 语义:opts.assign=true 走 location.href(登录/登出链保持新增历史)', async () => {
|
|
279
|
+
mockWindowLocation('/demo/');
|
|
280
|
+
const { traceRedirect } = await freshTrace();
|
|
281
|
+
traceRedirect('https://auth.autional.local/?logout=1', { reason: 'logout', assign: true });
|
|
282
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
283
|
+
expect(assignCalls).toHaveLength(1);
|
|
284
|
+
expect(assignCalls[0]).toContain('logout=1');
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('跳转执行防御(非法目标不炸页)', () => {
|
|
289
|
+
it('replace 抛 SyntaxError(IP 主机派生的 brand.127.0.0.1)→ 不冒泡,记 error-exit', async () => {
|
|
290
|
+
mockWindowLocation('/demo/');
|
|
291
|
+
const { traceRedirect } = await freshTrace();
|
|
292
|
+
replaceSpy.mockImplementationOnce(() => {
|
|
293
|
+
throw new SyntaxError(
|
|
294
|
+
"Failed to execute 'replace' on 'Location': 'http://brand.127.0.0.1/' is not a valid URL.",
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(() =>
|
|
299
|
+
traceRedirect('http://brand.127.0.0.1/?redirect=x', { reason: 'funnel-brand' }),
|
|
300
|
+
).not.toThrow();
|
|
301
|
+
|
|
302
|
+
const entries = (window as any).__authTrace.entries() as any[];
|
|
303
|
+
const errEntry = entries.find((e) => e.ev === 'error-exit');
|
|
304
|
+
expect(errEntry).toBeTruthy();
|
|
305
|
+
expect(errEntry.reason).toBe('invalid-target');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('防御后 pending 复位:随后合法跳转照常执行', async () => {
|
|
309
|
+
mockWindowLocation('/demo/');
|
|
310
|
+
const { traceRedirect } = await freshTrace();
|
|
311
|
+
replaceSpy.mockImplementationOnce(() => {
|
|
312
|
+
throw new SyntaxError('bad url');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
traceRedirect('http://brand.127.0.0.1/?redirect=x', { reason: 'funnel-brand' });
|
|
316
|
+
traceRedirect('https://brand.autional.local/?redirect=y', { reason: 'funnel-brand' });
|
|
317
|
+
|
|
318
|
+
expect(replaceSpy).toHaveBeenCalledTimes(2);
|
|
319
|
+
expect(String(replaceSpy.mock.calls[1][0])).toContain('brand.autional.local');
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { buildLoginUrl, buildLogoutUrl, isAuthLoginSurface } 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
|
+
});
|
|
220
|
+
|
|
221
|
+
// ============================================================
|
|
222
|
+
// F-W8b 修复②:登录承接面判定(onUnauthorized 抑制整页弹走的判据)
|
|
223
|
+
// 承接面(只清理不导航):`/`、`/login`、`/<slug>/login`、`/error`
|
|
224
|
+
// 非承接面(照常弹登录页):应用页、PKCE 发起页、其它静态路由
|
|
225
|
+
// ============================================================
|
|
226
|
+
function mockPathname(pathname: string): void {
|
|
227
|
+
vi.stubGlobal('window', {
|
|
228
|
+
location: { pathname, hostname: 'auth.autional.local', protocol: 'https:' },
|
|
229
|
+
__APP_CONFIG__: MOCK_CONFIG,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
describe('isAuthLoginSurface', () => {
|
|
234
|
+
beforeEach(() => {
|
|
235
|
+
vi.restoreAllMocks();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it.each([
|
|
239
|
+
['裸根入口', '/', true],
|
|
240
|
+
['/login 入口', '/login', true],
|
|
241
|
+
['错误页', '/error', true],
|
|
242
|
+
['租户登录页', '/demo/login', true],
|
|
243
|
+
['带尾斜杠的租户登录页', '/demo/login/', true],
|
|
244
|
+
['应用页 dashboard', '/demo/dashboard', false],
|
|
245
|
+
['租户 index(守卫跳转页)', '/demo', false],
|
|
246
|
+
['裸 dashboard', '/dashboard', false],
|
|
247
|
+
['PKCE 发起页', '/oauth/api/v1/oauth/authorize', false],
|
|
248
|
+
['重置密码页', '/reset-password', false],
|
|
249
|
+
['注册页', '/demo/register', false],
|
|
250
|
+
])('%s(%s)→ %s', (_name, pathname, expected) => {
|
|
251
|
+
mockPathname(pathname as string);
|
|
252
|
+
expect(isAuthLoginSurface()).toBe(expected);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('服务端渲染(无 window)→ false,不抑制', () => {
|
|
256
|
+
vi.stubGlobal('window', undefined);
|
|
257
|
+
expect(isAuthLoginSurface()).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -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 'autional-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
|
+
});
|