@autional-cn/shared 0.1.0-rc.3 → 0.1.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -2
- package/src/auth/__tests__/auth-trace.test.ts +237 -0
- package/src/auth/__tests__/build-login-url.test.ts +41 -1
- package/src/auth/__tests__/session-terminal-redirect.test.ts +138 -0
- package/src/auth/auth-trace.ts +473 -0
- package/src/auth/oauth-login.ts +15 -3
- package/src/auth/roles.ts +16 -0
- package/src/auth/service.ts +47 -20
- package/src/components/RequireAuth.tsx +6 -1
- package/src/components/TenantRootRedirect.tsx +3 -2
- package/src/components/__tests__/RequireAuth.test.tsx +42 -5
- package/src/components/__tests__/TenantRootRedirect.test.tsx +13 -5
- package/src/generated/api.ts +28 -4
- package/src/generated/queries.ts +20 -1
- package/src/generated/types.ts +93 -50
- package/src/hooks/useLogout.ts +3 -1
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@autional-cn/shared",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.5",
|
|
4
4
|
"description": "Autional shared layer — API client, auth, types, hooks, utils",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -31,10 +31,15 @@
|
|
|
31
31
|
"zustand": "5.0.13"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
+
"@testing-library/jest-dom": "6.9.1",
|
|
35
|
+
"@testing-library/react": "16.3.2",
|
|
36
|
+
"jsdom": "29.1.1",
|
|
34
37
|
"react": "19.2.5",
|
|
35
38
|
"react-dom": "19.2.5",
|
|
36
39
|
"typescript": "5.8.3",
|
|
37
|
-
"
|
|
40
|
+
"vite": "6.4.2",
|
|
41
|
+
"vitest": "3.2.0",
|
|
42
|
+
"@autional-cn/tsconfig": "0.1.0-rc"
|
|
38
43
|
},
|
|
39
44
|
"peerDependencies": {
|
|
40
45
|
"react": ">=18.0.0",
|
|
@@ -0,0 +1,237 @@
|
|
|
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('pending 抑制:提示条未决时再来一次 interstitial 被记为 suppressed', async () => {
|
|
158
|
+
mockWindowLocation('/demo/dashboard');
|
|
159
|
+
const { traceRedirect } = await freshTrace();
|
|
160
|
+
vi.useFakeTimers();
|
|
161
|
+
|
|
162
|
+
traceRedirect('https://auth.autional.local/demo/login', { reason: 'unauthenticated', kind: 'interstitial' });
|
|
163
|
+
traceRedirect('https://auth.autional.local/other/login', { reason: 'unauthenticated', kind: 'interstitial' });
|
|
164
|
+
|
|
165
|
+
const entries = (window as any).__authTrace.entries() as any[];
|
|
166
|
+
expect(entries.filter((e) => e.ev === 'suppressed')).toHaveLength(1);
|
|
167
|
+
vi.advanceTimersByTime(1600);
|
|
168
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
169
|
+
expect(String(replaceSpy.mock.calls[0][0])).toContain('/demo/login');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('funnel(缺省 kind):静默直跳,不出现提示条', async () => {
|
|
173
|
+
mockWindowLocation('/');
|
|
174
|
+
const { traceRedirect } = await freshTrace();
|
|
175
|
+
traceRedirect('https://brand.autional.local/?redirect=x', { reason: 'funnel-brand' });
|
|
176
|
+
expect(notice()).toBeNull();
|
|
177
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('层 ② debug 确认页', () => {
|
|
182
|
+
it('?debug=auth:不自动跳,确认页出现;点「继续前往」才 replace', async () => {
|
|
183
|
+
mockWindowLocation('/demo/dashboard', '?debug=auth');
|
|
184
|
+
const { traceRedirect } = await freshTrace();
|
|
185
|
+
traceRedirect('https://auth.autional.local/demo/login?redirect=x', {
|
|
186
|
+
reason: 'session-expired',
|
|
187
|
+
kind: 'interstitial',
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
191
|
+
expect(confirm()).not.toBeNull();
|
|
192
|
+
expect(confirm()!.textContent).toContain('整页跳转确认');
|
|
193
|
+
|
|
194
|
+
const goBtn = Array.from(confirm()!.querySelectorAll('button')).find((b) =>
|
|
195
|
+
b.textContent!.includes('继续前往'),
|
|
196
|
+
)!;
|
|
197
|
+
goBtn.click();
|
|
198
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
199
|
+
expect(String(replaceSpy.mock.calls[0][0])).toContain('debug=auth');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('确认页点「留在本页」:不跳 + pending 复位', async () => {
|
|
203
|
+
mockWindowLocation('/demo/dashboard', '?debug=auth');
|
|
204
|
+
const { traceRedirect } = await freshTrace();
|
|
205
|
+
traceRedirect('https://auth.autional.local/demo/login', { reason: 'unauthenticated', kind: 'funnel' });
|
|
206
|
+
|
|
207
|
+
const stayBtn = Array.from(confirm()!.querySelectorAll('button')).find((b) =>
|
|
208
|
+
b.textContent!.includes('留在本页'),
|
|
209
|
+
)!;
|
|
210
|
+
stayBtn.click();
|
|
211
|
+
expect(confirm()).toBeNull();
|
|
212
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('debug 标志经 sessionStorage 保持并随跳转透传(无需每次带 ?debug=auth)', async () => {
|
|
216
|
+
mockWindowLocation('/demo/');
|
|
217
|
+
const { traceRedirect } = await freshTrace();
|
|
218
|
+
(window as any).__authTrace.on();
|
|
219
|
+
|
|
220
|
+
traceRedirect('https://auth.autional.local/demo/login', { reason: 'unauthenticated' });
|
|
221
|
+
expect(confirm()).not.toBeNull();
|
|
222
|
+
const goBtn = Array.from(confirm()!.querySelectorAll('button')).find((b) =>
|
|
223
|
+
b.textContent!.includes('继续前往'),
|
|
224
|
+
)!;
|
|
225
|
+
goBtn.click();
|
|
226
|
+
expect(String(replaceSpy.mock.calls[0][0])).toContain('debug=auth');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('assign 语义:opts.assign=true 走 location.href(登录/登出链保持新增历史)', async () => {
|
|
230
|
+
mockWindowLocation('/demo/');
|
|
231
|
+
const { traceRedirect } = await freshTrace();
|
|
232
|
+
traceRedirect('https://auth.autional.local/?logout=1', { reason: 'logout', assign: true });
|
|
233
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
234
|
+
expect(assignCalls).toHaveLength(1);
|
|
235
|
+
expect(assignCalls[0]).toContain('logout=1');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { buildLoginUrl, buildLogoutUrl } from '../roles';
|
|
2
|
+
import { buildLoginUrl, buildLogoutUrl, isAuthLoginSurface } from '../roles';
|
|
3
3
|
|
|
4
4
|
const MOCK_CONFIG = {
|
|
5
5
|
VITE_PORTAL_CONFIG: {
|
|
@@ -217,3 +217,43 @@ describe('buildLoginUrl', () => {
|
|
|
217
217
|
});
|
|
218
218
|
});
|
|
219
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,138 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// ============================================================
|
|
5
|
+
// F-W8b 修复②:会话终结(onUnauthorized)与刷新失败处置的整链回归锁
|
|
6
|
+
// - 已在 auth 登录承接面(`/<slug>/login`、`/error`、裸根入口)→ 只清会话,
|
|
7
|
+
// 不整页弹走(否则 buildLoginUrl 防嵌套截断会丢掉 URL 中的 redirect 回程目标,
|
|
8
|
+
// 且构成一次整页闪烁 —— 存量过期 token 在登录页「静默弹走」)
|
|
9
|
+
// - 非承接面 → 照常清理 + 整页弹登录页(带 slug 与回程 redirect)
|
|
10
|
+
// - refreshToken onFailure:'clear' 只清理不导航;'none' 零副作用;缺省 'redirect'
|
|
11
|
+
// ============================================================
|
|
12
|
+
|
|
13
|
+
const { mockPost } = vi.hoisted(() => ({ mockPost: vi.fn() }));
|
|
14
|
+
|
|
15
|
+
vi.mock('axios', () => ({ default: { post: mockPost } }));
|
|
16
|
+
vi.mock('../../api/bff-client', () => ({ bffLogout: vi.fn(() => Promise.resolve()) }));
|
|
17
|
+
|
|
18
|
+
const replaceSpy = vi.fn();
|
|
19
|
+
const originalLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location');
|
|
20
|
+
|
|
21
|
+
// 只替换 window.location(不动 window 本体 —— stubGlobal 会连 localStorage 一起遮蔽,
|
|
22
|
+
// zustand persist 在 setState 时崩)。字段给纯值,绕开 jsdom Location 的 brand check。
|
|
23
|
+
function mockWindowLocation(pathname: string): void {
|
|
24
|
+
Object.defineProperty(window, 'location', {
|
|
25
|
+
configurable: true,
|
|
26
|
+
value: {
|
|
27
|
+
pathname,
|
|
28
|
+
href: `https://auth.autional.local${pathname}`,
|
|
29
|
+
origin: 'https://auth.autional.local',
|
|
30
|
+
hostname: 'auth.autional.local',
|
|
31
|
+
protocol: 'https:',
|
|
32
|
+
replace: replaceSpy,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
(window as any).__APP_CONFIG__ = { VITE_PORTAL_CONFIG: { auth: { host: 'auth', base: '' } } };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 剥除 authTrace 面包屑(rt=)后比较;rt 装饰本身由 toMatch 锁定。 */
|
|
39
|
+
function withoutRt(url: string): string {
|
|
40
|
+
const u = new URL(url);
|
|
41
|
+
u.searchParams.delete('rt');
|
|
42
|
+
return u.toString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 每例取全新模块实例:清零 service.ts 的模块级重入锁(redirectingToLogin)。 */
|
|
46
|
+
async function freshService() {
|
|
47
|
+
vi.resetModules();
|
|
48
|
+
const svc = await import('../service');
|
|
49
|
+
const store = await import('../store');
|
|
50
|
+
return { AuthService: svc.AuthService, useAuthStore: store.useAuthStore };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
vi.clearAllMocks();
|
|
55
|
+
localStorage.clear();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
if (originalLocationDescriptor) {
|
|
60
|
+
Object.defineProperty(window, 'location', originalLocationDescriptor);
|
|
61
|
+
}
|
|
62
|
+
delete (window as any).__APP_CONFIG__;
|
|
63
|
+
vi.useRealTimers();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('onUnauthorized 承接面抑制(F-W8b 修复②)', () => {
|
|
67
|
+
it('已在 /<slug>/login:清理会话但不整页弹走(回程 redirect 不丢)', async () => {
|
|
68
|
+
mockWindowLocation('/demo/login');
|
|
69
|
+
const { AuthService, useAuthStore } = await freshService();
|
|
70
|
+
useAuthStore.setState({ accessToken: 'at-old', refreshToken: 'rt-old', isAuthenticated: true });
|
|
71
|
+
|
|
72
|
+
AuthService.onUnauthorized();
|
|
73
|
+
|
|
74
|
+
expect(useAuthStore.getState().accessToken).toBeNull();
|
|
75
|
+
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
|
76
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('已在 /error:同样只清理不弹走(错误页有自身倒计时出口)', async () => {
|
|
80
|
+
mockWindowLocation('/error');
|
|
81
|
+
const { AuthService, useAuthStore } = await freshService();
|
|
82
|
+
useAuthStore.setState({ accessToken: 'at-old', refreshToken: 'rt-old', isAuthenticated: true });
|
|
83
|
+
|
|
84
|
+
AuthService.onUnauthorized();
|
|
85
|
+
|
|
86
|
+
expect(useAuthStore.getState().accessToken).toBeNull();
|
|
87
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('非承接面 /demo/dashboard:清理 + 整页弹登录页(带 slug 与回程 redirect)', async () => {
|
|
91
|
+
mockWindowLocation('/demo/dashboard');
|
|
92
|
+
const { AuthService, useAuthStore } = await freshService();
|
|
93
|
+
useAuthStore.setState({ accessToken: 'at-old', refreshToken: 'rt-old', isAuthenticated: true });
|
|
94
|
+
|
|
95
|
+
vi.useFakeTimers();
|
|
96
|
+
AuthService.onUnauthorized();
|
|
97
|
+
vi.runAllTimers();
|
|
98
|
+
|
|
99
|
+
expect(useAuthStore.getState().accessToken).toBeNull();
|
|
100
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
101
|
+
const url = String(replaceSpy.mock.calls[0][0]);
|
|
102
|
+
expect(url).toMatch(/[?&]rt=auth\.session-expired\.[0-9a-z]+$/);
|
|
103
|
+
expect(withoutRt(url)).toBe(
|
|
104
|
+
'https://auth.autional.local/demo/login?redirect=' +
|
|
105
|
+
encodeURIComponent('https://auth.autional.local/demo/dashboard') +
|
|
106
|
+
'&from_requireauth=1',
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('refreshToken onFailure 处置', () => {
|
|
112
|
+
it("'clear':刷新失败只清理会话,不做任何导航(导航权交调用方)", async () => {
|
|
113
|
+
mockWindowLocation('/demo/dashboard');
|
|
114
|
+
const { AuthService, useAuthStore } = await freshService();
|
|
115
|
+
useAuthStore.setState({ accessToken: 'at-old', refreshToken: 'rt-opaque-1', isAuthenticated: true });
|
|
116
|
+
mockPost.mockRejectedValue(new Error('refresh rejected'));
|
|
117
|
+
|
|
118
|
+
const at = await AuthService.refreshToken({ onFailure: 'clear' });
|
|
119
|
+
|
|
120
|
+
expect(at).toBeNull();
|
|
121
|
+
expect(useAuthStore.getState().accessToken).toBeNull();
|
|
122
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("'none':刷新失败零副作用(会话保留、不导航)", async () => {
|
|
126
|
+
mockWindowLocation('/demo/dashboard');
|
|
127
|
+
const { AuthService, useAuthStore } = await freshService();
|
|
128
|
+
useAuthStore.setState({ accessToken: 'at-old', refreshToken: 'rt-opaque-1', isAuthenticated: true });
|
|
129
|
+
mockPost.mockRejectedValue(new Error('refresh rejected'));
|
|
130
|
+
|
|
131
|
+
const at = await AuthService.refreshToken({ onFailure: 'none' });
|
|
132
|
+
|
|
133
|
+
expect(at).toBeNull();
|
|
134
|
+
expect(useAuthStore.getState().accessToken).toBe('at-old');
|
|
135
|
+
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
|
136
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
});
|