@autional/shared 0.1.0-rc.34 → 0.1.0-rc.36
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 -661
- package/package.json +1 -1
- package/src/auth/__tests__/build-login-url.test.ts +61 -14
- package/src/auth/roles.ts +32 -9
- package/src/auth/store.ts +2 -1
- package/src/components/TenantIndexGuard.tsx +20 -6
- package/src/components/TenantRootRedirect.tsx +8 -3
- package/src/components/__tests__/TenantIndexGuard.test.tsx +63 -7
- package/src/components/__tests__/TenantRootRedirect.test.tsx +7 -2
- package/src/generated/types.ts +54 -54
- package/src/hooks/__tests__/useLogout.test.tsx +25 -0
- package/src/hooks/useLogout.ts +4 -3
package/package.json
CHANGED
|
@@ -182,38 +182,85 @@ describe('buildLoginUrl', () => {
|
|
|
182
182
|
});
|
|
183
183
|
});
|
|
184
184
|
|
|
185
|
-
describe('buildLogoutUrl(
|
|
185
|
+
describe('buildLogoutUrl(TASK-LS-08 同源标准端点 + legacy 兜底)', () => {
|
|
186
186
|
const AUTH_CONFIG = {
|
|
187
187
|
VITE_PORTAL_CONFIG: {
|
|
188
188
|
auth: { host: 'auth', base: '' },
|
|
189
189
|
},
|
|
190
190
|
};
|
|
191
|
+
/** env 级 client_id(三级来源之二) */
|
|
192
|
+
const CID_CONFIG = {
|
|
193
|
+
...AUTH_CONFIG,
|
|
194
|
+
VITE_OAUTH_CLIENT_ID: 'cid-env',
|
|
195
|
+
};
|
|
191
196
|
|
|
192
|
-
|
|
193
|
-
|
|
197
|
+
beforeEach(() => {
|
|
198
|
+
// localStorage 桩按需设置;测试间清掉全局桩防跨用例泄漏(node 环境无真 localStorage)
|
|
199
|
+
vi.unstubAllGlobals();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('门户域登出:同源标准端点 + client_id + post_logout_redirect_uri=<origin>/ + state=returnUrl', () => {
|
|
203
|
+
mockWindow('app.autional.com', 'https:', 'https://app.autional.com', CID_CONFIG);
|
|
194
204
|
const url = buildLogoutUrl('https://app.autional.com/demo/');
|
|
195
205
|
expect(url).toBe(
|
|
196
|
-
'https://
|
|
197
|
-
encodeURIComponent('https://app.autional.com/
|
|
198
|
-
'&
|
|
206
|
+
'https://app.autional.com/oauth/api/v1/oauth/logout?client_id=cid-env&post_logout_redirect_uri=' +
|
|
207
|
+
encodeURIComponent('https://app.autional.com/') +
|
|
208
|
+
'&state=' +
|
|
209
|
+
encodeURIComponent('https://app.autional.com/demo/'),
|
|
199
210
|
);
|
|
200
211
|
});
|
|
201
212
|
|
|
202
|
-
it('auth
|
|
203
|
-
mockWindow('auth.autional.local', 'https:', 'https://auth.autional.local',
|
|
213
|
+
it('auth 域租户页登出同样走同源标准端点(不再裸根 / 不经 /<slug>/login)', () => {
|
|
214
|
+
mockWindow('auth.autional.local', 'https:', 'https://auth.autional.local', CID_CONFIG);
|
|
204
215
|
const url = buildLogoutUrl('https://auth.autional.local/acme-corp/dashboard');
|
|
205
216
|
expect(url).toBe(
|
|
206
|
-
'https://auth.autional.local
|
|
207
|
-
encodeURIComponent('https://auth.autional.local/
|
|
208
|
-
'&
|
|
217
|
+
'https://auth.autional.local/oauth/api/v1/oauth/logout?client_id=cid-env&post_logout_redirect_uri=' +
|
|
218
|
+
encodeURIComponent('https://auth.autional.local/') +
|
|
219
|
+
'&state=' +
|
|
220
|
+
encodeURIComponent('https://auth.autional.local/acme-corp/dashboard'),
|
|
209
221
|
);
|
|
210
222
|
expect(url).not.toContain('/acme-corp/login');
|
|
211
223
|
});
|
|
212
224
|
|
|
213
|
-
it('
|
|
214
|
-
mockWindow('
|
|
225
|
+
it('无回程:仅 client_id + post_logout_redirect_uri,不产 state', () => {
|
|
226
|
+
mockWindow('app.autional.com', 'https:', 'https://app.autional.com', CID_CONFIG);
|
|
215
227
|
const url = buildLogoutUrl();
|
|
216
|
-
expect(url).toBe(
|
|
228
|
+
expect(url).toBe(
|
|
229
|
+
'https://app.autional.com/oauth/api/v1/oauth/logout?client_id=cid-env&post_logout_redirect_uri=' +
|
|
230
|
+
encodeURIComponent('https://app.autional.com/'),
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('client_id 三级来源(会话持久化 → env → 空)', () => {
|
|
235
|
+
it('localStorage 会话值优先于 env', () => {
|
|
236
|
+
vi.stubGlobal('localStorage', { getItem: () => 'cid-session' });
|
|
237
|
+
mockWindow('app.autional.com', 'https:', 'https://app.autional.com', CID_CONFIG);
|
|
238
|
+
const url = buildLogoutUrl('https://app.autional.com/demo/');
|
|
239
|
+
expect(url).toContain('client_id=cid-session');
|
|
240
|
+
expect(url).not.toContain('cid-env');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('localStorage 空 → env 兜底', () => {
|
|
244
|
+
vi.stubGlobal('localStorage', { getItem: () => null });
|
|
245
|
+
mockWindow('app.autional.com', 'https:', 'https://app.autional.com', CID_CONFIG);
|
|
246
|
+
expect(buildLogoutUrl()).toContain('client_id=cid-env');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('双空 → legacy 兜底:auth 裸根 + redirect 回程 + logout=1', () => {
|
|
250
|
+
mockWindow('app.autional.com', 'https:', 'https://app.autional.com', AUTH_CONFIG);
|
|
251
|
+
const url = buildLogoutUrl('https://app.autional.com/demo/');
|
|
252
|
+
expect(url).toBe(
|
|
253
|
+
'https://auth.autional.com/?redirect=' +
|
|
254
|
+
encodeURIComponent('https://app.autional.com/demo/') +
|
|
255
|
+
'&logout=1',
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('双空 + 无回程 → legacy 裸根 + logout=1(现行为保持)', () => {
|
|
260
|
+
mockWindow('auth.autional.local', 'https:', 'https://auth.autional.local', AUTH_CONFIG);
|
|
261
|
+
const url = buildLogoutUrl();
|
|
262
|
+
expect(url).toBe('https://auth.autional.local/?logout=1');
|
|
263
|
+
});
|
|
217
264
|
});
|
|
218
265
|
});
|
|
219
266
|
});
|
package/src/auth/roles.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { getPortalUrl } from '../config';
|
|
11
|
+
import { resolveClientIdForSession } from './oauth-client-id-store';
|
|
11
12
|
|
|
12
13
|
/** auth-pages 的静态/受保护路由 — 首段不会是租户 slug */
|
|
13
14
|
const AUTH_ROUTE_RESERVED = new Set([
|
|
@@ -20,6 +21,7 @@ const AUTH_ROUTE_RESERVED = new Set([
|
|
|
20
21
|
'privacy',
|
|
21
22
|
'error',
|
|
22
23
|
'logout',
|
|
24
|
+
'logout-confirm',
|
|
23
25
|
'passkey',
|
|
24
26
|
'reapply',
|
|
25
27
|
'mfa',
|
|
@@ -37,20 +39,41 @@ const AUTH_ROUTE_RESERVED = new Set([
|
|
|
37
39
|
]);
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
|
-
*
|
|
42
|
+
* 登出 URL(TASK-LS-08):同源标准端点(RP-Initiated Logout 无 hint 形态)优先,
|
|
43
|
+
* client_id 不可得时保持 legacy 形态。
|
|
41
44
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
45
|
+
* 标准端点形态(ADR-12 同源直通):`${origin}/oauth/api/v1/oauth/logout`
|
|
46
|
+
* - client_id:本部署的 OAuth 客户端(`resolveClientIdForSession` 三级来源:会话持久化
|
|
47
|
+
* localStorage `oauth_client_id` → env),服务端据此做 post_logout_redirect_uri 注册面白名单校验;
|
|
48
|
+
* - post_logout_redirect_uri:本门户 origin 根(`<origin>/`,注册形态同 PlatformPortalRedirectURIs);
|
|
49
|
+
* - state:returnUrl 的 URL-safe 编码(门户零行为驱动——服务端只原样回拼到回跳 URL,门户不解析)。
|
|
50
|
+
* 浏览器整页导航不带 Bearer → 服务端「无 hint 无会话浏览器」支 302 确认页承接;
|
|
51
|
+
* 用户确认后由确认页经 BFF(会话 cookie → Bearer)触达端点,走认证会话支并导航服务端 location。
|
|
52
|
+
*
|
|
53
|
+
* legacy 兜底(D2=A 兼容分支保留,EntryRouter 承接):client_id 不可得 → auth 域**裸根**入口 +
|
|
54
|
+
* `logout=1` 显式登出意图标记。刻意不复用 buildLoginUrl:其租户 slug 保留会把 auth 域页面的
|
|
55
|
+
* 登出落到 `/<slug>/login`(登录页路由,不经入口路由),`logout=1` 在那里被静默忽略;
|
|
44
56
|
* 带会话回程甚至会被登录页 checkAndRedirect 直接放行成静默重登(F-W5c)。
|
|
45
|
-
* 裸根 = EntryRouter 唯一登出闸门:先终结 auth
|
|
46
|
-
*
|
|
47
|
-
* 登录后走默认去向,去向选择权交还用户);否则落 brand 裸根。
|
|
57
|
+
* 裸根 = EntryRouter 唯一登出闸门:先终结 auth 域会话,再决定落点——回程能解析出真实租户 →
|
|
58
|
+
* 裸 `/<slug>/login`;否则落 brand 裸根。
|
|
48
59
|
*/
|
|
49
60
|
export function buildLogoutUrl(returnUrl?: string): string {
|
|
50
61
|
const base = getPortalUrl('auth');
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
62
|
+
// legacy 兜底:现行为保持(auth 裸根 + logout=1 意图标记)
|
|
63
|
+
const legacy = (): string => {
|
|
64
|
+
if (!base) return '/';
|
|
65
|
+
const redirect = returnUrl ? `redirect=${encodeURIComponent(returnUrl)}&` : '';
|
|
66
|
+
return `${base}/?${redirect}logout=1`;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const clientId = resolveClientIdForSession();
|
|
70
|
+
if (!clientId || typeof window === 'undefined' || !window.location?.origin) return legacy();
|
|
71
|
+
|
|
72
|
+
const params = new URLSearchParams();
|
|
73
|
+
params.set('client_id', clientId);
|
|
74
|
+
params.set('post_logout_redirect_uri', `${window.location.origin}/`);
|
|
75
|
+
if (returnUrl) params.set('state', returnUrl);
|
|
76
|
+
return `${window.location.origin}/oauth/api/v1/oauth/logout?${params.toString()}`;
|
|
54
77
|
}
|
|
55
78
|
|
|
56
79
|
/**
|
package/src/auth/store.ts
CHANGED
|
@@ -201,7 +201,8 @@ export function loginWithTokens(
|
|
|
201
201
|
*
|
|
202
202
|
* redirectTo(U350):显式落点(会话过期→auth /error、注销完成→/<slug>/login?account_deleted=true 等
|
|
203
203
|
* 旗标页)——清理完成后整页前往。缺省仅清会话、不导航;注意与 useLogout 口径区分:该 hook 走
|
|
204
|
-
* buildLogoutUrl
|
|
204
|
+
* buildLogoutUrl 登出流(LS-08:同源标准端点;client_id 不可得时退 legacy logout=1 交入口路由),
|
|
205
|
+
* 不得用于需要保留业务 query 旗标的目标。
|
|
205
206
|
*/
|
|
206
207
|
export async function logout(redirectTo?: string): Promise<void> {
|
|
207
208
|
const { AuthService } = await import('./service');
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* (不复制 4 份,违反 DRY)。
|
|
6
6
|
*
|
|
7
7
|
* - usePublicTenantSlugs: 调公开 tenants API(无认证端点),staleTime 5m 缓存,
|
|
8
|
-
* retry 1
|
|
8
|
+
* retry 1 + 8s 超时;失败上抛(不吞成空数组成功)——放行语义由消费方
|
|
9
|
+
* (TenantIndexGuard / TenantRootRedirect)在失败态自行 fail-open(rc.35)。
|
|
9
10
|
* - TenantIndexGuard: 租户 slug 白名单守卫(P0-2),防止未知 slug 被贪婪渲染为
|
|
10
11
|
* 租户内容(index 首页与 /:slug/... 子路由通用)。404 页由各 portal 通过
|
|
11
12
|
* notFound prop 注入(shared 组件不能 import 具体 app 的 not-found/page)。
|
|
@@ -30,11 +31,20 @@ export interface PublicTenantInfo {
|
|
|
30
31
|
id?: string;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/** 公开租户名单请求超时(ms)。跨境链路(Vercel edge → 源站)实测新连接丢 ~45%,
|
|
35
|
+
* 失败连接 edge 端要 ~31s 才吐 502;正常成功 ≤2s,8s 留 4x 余量,失败快速交给 retry。
|
|
36
|
+
* (rc.35:此前无超时 + catch 吞错 → retry 死码、失败被当"空名单"成功。) */
|
|
37
|
+
export const PUBLIC_TENANTS_FETCH_TIMEOUT_MS = 8000;
|
|
38
|
+
|
|
33
39
|
/**
|
|
34
40
|
* 公开租户列表(无认证端点,用于校验 URL slug 有效性)。
|
|
35
41
|
* 从 end-user-portal App.tsx 原样抽取。
|
|
36
42
|
* - staleTime 5m(plan §5 资源列:public tenants API 有缓存)
|
|
37
|
-
* - retry 1
|
|
43
|
+
* - retry 1;非 2xx / 网络错 / 超时一律上抛(不吞成空数组成功)——
|
|
44
|
+
* error 态不落缓存,下次调用自然重试
|
|
45
|
+
* - 消费方 fail-open:名单缺失(error 态 data=undefined 或空数组)→ 不拦截
|
|
46
|
+
* (TenantIndexGuard 渲染 children;TenantRootRedirect 漏斗 brand;
|
|
47
|
+
* resolveSlugAgainstList 信任传入 slug)
|
|
38
48
|
*
|
|
39
49
|
* ⚠️ 响应契约:tenant-service ListPublicTenants 返回 dto_base.ListResponse
|
|
40
50
|
* (`{code, message, items, total, pagination, timestamp}`,items 才是数组)。
|
|
@@ -44,14 +54,18 @@ export function usePublicTenantSlugs() {
|
|
|
44
54
|
return useQuery<Array<PublicTenantInfo>>({
|
|
45
55
|
queryKey: ['public-tenants'],
|
|
46
56
|
queryFn: async () => {
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
const timer = setTimeout(() => controller.abort(), PUBLIC_TENANTS_FETCH_TIMEOUT_MS);
|
|
47
59
|
try {
|
|
48
|
-
const res = await fetch('/bff/tenant/api/v1/tenant/public/tenants'
|
|
49
|
-
|
|
60
|
+
const res = await fetch('/bff/tenant/api/v1/tenant/public/tenants', {
|
|
61
|
+
signal: controller.signal,
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) throw new Error(`public tenants API HTTP ${res.status}`);
|
|
50
64
|
const json = await res.json();
|
|
51
65
|
const list = json?.items ?? json?.data ?? json ?? [];
|
|
52
66
|
return Array.isArray(list) ? list : [];
|
|
53
|
-
}
|
|
54
|
-
|
|
67
|
+
} finally {
|
|
68
|
+
clearTimeout(timer);
|
|
55
69
|
}
|
|
56
70
|
},
|
|
57
71
|
staleTime: 5 * 60 * 1000,
|
|
@@ -18,7 +18,11 @@ import { usePublicTenantSlugs } from './TenantIndexGuard';
|
|
|
18
18
|
*/
|
|
19
19
|
export function TenantRootRedirect() {
|
|
20
20
|
const currentTenantId = useCurrentTenantId();
|
|
21
|
-
const {
|
|
21
|
+
const {
|
|
22
|
+
data: knownTenants,
|
|
23
|
+
isSuccess: tenantsLoaded,
|
|
24
|
+
isError: tenantsError,
|
|
25
|
+
} = usePublicTenantSlugs();
|
|
22
26
|
|
|
23
27
|
const slug = useMemo(() => {
|
|
24
28
|
if (!currentTenantId || !tenantsLoaded) return undefined;
|
|
@@ -35,7 +39,8 @@ export function TenantRootRedirect() {
|
|
|
35
39
|
}
|
|
36
40
|
// 有会话但公开名单尚未返回:等待(名单是 slug 唯一权威,不得抢跑漏斗 brand)。
|
|
37
41
|
// 无会话(currentTenantId 空)不受此闸,立即漏斗。
|
|
38
|
-
|
|
42
|
+
// 名单接口彻底失败(rc.35:重试耗尽后 error 态)不等待:按无匹配处理漏斗 brand,不卡死。
|
|
43
|
+
if (currentTenantId && !tenantsLoaded && !tenantsError) return;
|
|
39
44
|
const brand = getPortalUrl('brand');
|
|
40
45
|
// 门户不可派生(如经 IP 主机访问):保持当前页,不导航(与 EntryRouter 同约定)
|
|
41
46
|
if (!brand) return;
|
|
@@ -43,7 +48,7 @@ export function TenantRootRedirect() {
|
|
|
43
48
|
window.location.origin + window.location.pathname,
|
|
44
49
|
)}`;
|
|
45
50
|
traceRedirect(target, { reason: 'funnel-brand' });
|
|
46
|
-
}, [slug, currentTenantId, tenantsLoaded]);
|
|
51
|
+
}, [slug, currentTenantId, tenantsLoaded, tenantsError]);
|
|
47
52
|
|
|
48
53
|
return null;
|
|
49
54
|
}
|
|
@@ -9,7 +9,9 @@ import { TenantIndexGuard, usePublicTenantSlugs } from '../TenantIndexGuard';
|
|
|
9
9
|
function createQueryClient() {
|
|
10
10
|
return new QueryClient({
|
|
11
11
|
defaultOptions: {
|
|
12
|
-
|
|
12
|
+
// retryDelay 0:hook 内部 retry:1 覆盖 retry,但重试退避仍走 defaultOptions,
|
|
13
|
+
// 不设 0 时失败用例要白等默认 1s 退避
|
|
14
|
+
queries: { retry: false, retryDelay: 0, gcTime: Infinity },
|
|
13
15
|
},
|
|
14
16
|
});
|
|
15
17
|
}
|
|
@@ -106,20 +108,34 @@ describe('usePublicTenantSlugs(响应契约解析)', () => {
|
|
|
106
108
|
await waitFor(() => expect(result).toEqual([{ id: '1', name: 'acme-corp' }]));
|
|
107
109
|
});
|
|
108
110
|
|
|
109
|
-
it('API 非 200 →
|
|
111
|
+
it('API 非 200 → 上抛进入 error 态,不吞成空数组成功(rc.35)', async () => {
|
|
110
112
|
stubFetchNonOk();
|
|
111
|
-
let
|
|
113
|
+
let state: { isError: boolean; data: unknown[] | undefined } | undefined;
|
|
112
114
|
function Harness() {
|
|
113
115
|
const q = usePublicTenantSlugs();
|
|
114
|
-
|
|
116
|
+
state = { isError: q.isError, data: q.data };
|
|
115
117
|
return null;
|
|
116
118
|
}
|
|
117
119
|
render(wrapper(<Harness />));
|
|
118
|
-
await waitFor(() => expect(
|
|
120
|
+
await waitFor(() => expect(state?.isError).toBe(true));
|
|
121
|
+
expect(state?.data).toBeUndefined();
|
|
119
122
|
});
|
|
120
123
|
|
|
121
|
-
it('网络异常 →
|
|
124
|
+
it('网络异常 → 上抛进入 error 态(retry 不再被吞错架空,rc.35)', async () => {
|
|
122
125
|
stubFetchReject();
|
|
126
|
+
let state: { isError: boolean; data: unknown[] | undefined } | undefined;
|
|
127
|
+
function Harness() {
|
|
128
|
+
const q = usePublicTenantSlugs();
|
|
129
|
+
state = { isError: q.isError, data: q.data };
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
render(wrapper(<Harness />));
|
|
133
|
+
await waitFor(() => expect(state?.isError).toBe(true));
|
|
134
|
+
expect(state?.data).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('fetch 携带 AbortSignal(8s 超时接线,rc.35)', async () => {
|
|
138
|
+
stubFetch([{ id: '1', name: 'acme-corp' }]);
|
|
123
139
|
let result: unknown[] | undefined;
|
|
124
140
|
function Harness() {
|
|
125
141
|
const q = usePublicTenantSlugs();
|
|
@@ -127,7 +143,39 @@ describe('usePublicTenantSlugs(响应契约解析)', () => {
|
|
|
127
143
|
return null;
|
|
128
144
|
}
|
|
129
145
|
render(wrapper(<Harness />));
|
|
130
|
-
await waitFor(() => expect(result).toEqual([]));
|
|
146
|
+
await waitFor(() => expect(result).toEqual([{ id: '1', name: 'acme-corp' }]));
|
|
147
|
+
const fetchMock = fetch as unknown as ReturnType<typeof vi.fn>;
|
|
148
|
+
const init = fetchMock.mock.calls[0][1] as RequestInit;
|
|
149
|
+
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('超时 abort(AbortError)→ 上抛进入 error 态,不吞(rc.35)', async () => {
|
|
153
|
+
const signals: AbortSignal[] = [];
|
|
154
|
+
vi.stubGlobal(
|
|
155
|
+
'fetch',
|
|
156
|
+
vi.fn((_url: string, init?: RequestInit) => {
|
|
157
|
+
return new Promise((_resolve, reject) => {
|
|
158
|
+
signals.push(init!.signal!);
|
|
159
|
+
init!.signal!.addEventListener('abort', () =>
|
|
160
|
+
reject(new DOMException('The operation was aborted.', 'AbortError')),
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
let isError = false;
|
|
166
|
+
function Harness() {
|
|
167
|
+
const q = usePublicTenantSlugs();
|
|
168
|
+
isError = q.isError;
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
render(wrapper(<Harness />));
|
|
172
|
+
// 模拟计时器到点 abort(计时器-到点→abort 是标准库行为,此处验 abort 错误的上抛路径);
|
|
173
|
+
// hook retry:1 → 两跳都要 abort 才耗尽重试
|
|
174
|
+
await waitFor(() => expect(signals.length).toBeGreaterThan(0));
|
|
175
|
+
signals[0].dispatchEvent(new Event('abort'));
|
|
176
|
+
await waitFor(() => expect(signals.length).toBeGreaterThan(1));
|
|
177
|
+
signals[1].dispatchEvent(new Event('abort'));
|
|
178
|
+
await waitFor(() => expect(isError).toBe(true));
|
|
131
179
|
});
|
|
132
180
|
|
|
133
181
|
it('响应无 items/data 且非数组 → 返回空数组', async () => {
|
|
@@ -178,6 +226,14 @@ describe('TenantIndexGuard(P0-2 slug 白名单守卫)', () => {
|
|
|
178
226
|
await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument());
|
|
179
227
|
});
|
|
180
228
|
|
|
229
|
+
it('名单接口失败(重试耗尽 error)→ fail-open 渲染 children,不白屏不 404(rc.35)', async () => {
|
|
230
|
+
stubFetchReject();
|
|
231
|
+
setPath('/any-slug');
|
|
232
|
+
render(wrapper(<TenantIndexGuard notFound={notFound}>{children}</TenantIndexGuard>));
|
|
233
|
+
await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument());
|
|
234
|
+
expect(screen.queryByTestId('notfound')).toBeNull();
|
|
235
|
+
});
|
|
236
|
+
|
|
181
237
|
it('slug 有效 + 子路径(/acme-corp/terms)→ 渲染 children(回归:子路由此前被段数检查误杀)', async () => {
|
|
182
238
|
stubFetch([{ id: '1', name: 'acme-corp' }]);
|
|
183
239
|
setPath('/acme-corp/terms');
|
|
@@ -34,7 +34,12 @@ import { getPortalUrl } from '../../config';
|
|
|
34
34
|
function wrapper(children: ReactNode) {
|
|
35
35
|
return (
|
|
36
36
|
<QueryClientProvider
|
|
37
|
-
client={
|
|
37
|
+
client={
|
|
38
|
+
new QueryClient({
|
|
39
|
+
// retryDelay 0:hook 内部 retry:1 覆盖 retry,重试退避仍走 defaultOptions
|
|
40
|
+
defaultOptions: { queries: { retry: false, retryDelay: 0, gcTime: Infinity } },
|
|
41
|
+
})
|
|
42
|
+
}
|
|
38
43
|
>
|
|
39
44
|
{children}
|
|
40
45
|
</QueryClientProvider>
|
|
@@ -154,7 +159,7 @@ describe('TenantRootRedirect 裸根漏斗', () => {
|
|
|
154
159
|
expect(withoutRt(String(mockState.replace.mock.calls[0][0]))).toBe(withoutRt(expectedBrandFunnel()));
|
|
155
160
|
});
|
|
156
161
|
|
|
157
|
-
it('
|
|
162
|
+
it('名单接口失败(error 态,rc.35 上抛)→ 有会话按无匹配处理,漏斗 brand(不卡死)', async () => {
|
|
158
163
|
mockState.currentTenantId = 't1';
|
|
159
164
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
|
|
160
165
|
|