@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,131 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// ============================================================
|
|
3
|
+
// AUTH-03:未认证 + 用户点「停留」不得留空白死路——
|
|
4
|
+
// ① 无 OAuth client(buildLoginUrl 链):停留 → 恢复态(需要登录/前往登录);点击再发起
|
|
5
|
+
// ② 有 OAuth client(PKCE 链):停留 → 恢复态;点击以 force 跳过 10s 防重入再发起
|
|
6
|
+
// ============================================================
|
|
7
|
+
import '@testing-library/jest-dom/vitest';
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import { act, fireEvent, render, screen, cleanup, waitFor } from '@testing-library/react';
|
|
10
|
+
|
|
11
|
+
const { mockState, mockInitiate, mockTrace } = vi.hoisted(() => ({
|
|
12
|
+
mockState: {
|
|
13
|
+
machineStatus: 'ready' as string,
|
|
14
|
+
tenantRoute: {
|
|
15
|
+
slug: null as string | null,
|
|
16
|
+
tenantId: null as string | null,
|
|
17
|
+
oauthClientId: null as string | null,
|
|
18
|
+
loading: false,
|
|
19
|
+
notFound: false,
|
|
20
|
+
unknownSlug: false,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
mockInitiate: vi.fn(),
|
|
24
|
+
mockTrace: vi.fn(),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock('../../auth/auth-machine', () => ({
|
|
28
|
+
useAuthMachine: () => ({ status: mockState.machineStatus }),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('../../auth/tenant-route-middleware', async (importOriginal) => ({
|
|
32
|
+
...(await importOriginal<typeof import('../../auth/tenant-route-middleware')>()),
|
|
33
|
+
useTenantRoute: () => mockState.tenantRoute,
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock('../../auth/oauth-login', () => ({
|
|
37
|
+
initiateOAuthLogin: (...args: any[]) => mockInitiate(...args),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('../../auth/auth-trace', () => ({
|
|
41
|
+
traceRedirect: (...args: any[]) => mockTrace(...args),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock('../../auth/service', () => ({
|
|
45
|
+
AuthService: {
|
|
46
|
+
getAccessToken: () => null,
|
|
47
|
+
getCurrentRole: () => 'admin',
|
|
48
|
+
getPermissions: () => [],
|
|
49
|
+
},
|
|
50
|
+
setBootstrapLock: vi.fn(),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
import { RequireAuth } from '../RequireAuth';
|
|
54
|
+
|
|
55
|
+
function renderGuard() {
|
|
56
|
+
return render(
|
|
57
|
+
<RequireAuth>
|
|
58
|
+
<div data-testid="protected">protected</div>
|
|
59
|
+
</RequireAuth>,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
mockState.machineStatus = 'ready';
|
|
65
|
+
mockState.tenantRoute = {
|
|
66
|
+
slug: null,
|
|
67
|
+
tenantId: null,
|
|
68
|
+
oauthClientId: null,
|
|
69
|
+
loading: false,
|
|
70
|
+
notFound: false,
|
|
71
|
+
unknownSlug: false,
|
|
72
|
+
};
|
|
73
|
+
delete (window as any).__APP_CONFIG__;
|
|
74
|
+
document.documentElement.lang = 'zh-CN';
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
cleanup();
|
|
79
|
+
vi.clearAllMocks();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('RequireAuth「停留」恢复态(AUTH-03)', () => {
|
|
83
|
+
it('无 OAuth client:停留 → 恢复态渲染,「前往登录」再发起(interstitial 重弹)', async () => {
|
|
84
|
+
mockState.machineStatus = 'unauthenticated';
|
|
85
|
+
renderGuard();
|
|
86
|
+
|
|
87
|
+
// effect → setTimeout(0) → traceRedirect(interstitial);未停留时仍是空白
|
|
88
|
+
await waitFor(() => expect(mockTrace).toHaveBeenCalledTimes(1));
|
|
89
|
+
expect(screen.queryByTestId('protected')).toBeNull();
|
|
90
|
+
expect(screen.queryByText('需要登录')).toBeNull();
|
|
91
|
+
|
|
92
|
+
const opts = mockTrace.mock.calls[0][1];
|
|
93
|
+
expect(opts.kind).toBe('interstitial');
|
|
94
|
+
expect(typeof opts.onStay).toBe('function');
|
|
95
|
+
|
|
96
|
+
// 用户点「停留」→ onStay → 恢复态(不再空白)
|
|
97
|
+
act(() => opts.onStay());
|
|
98
|
+
expect(await screen.findByText('需要登录')).toBeInTheDocument();
|
|
99
|
+
|
|
100
|
+
act(() => fireEvent.click(screen.getByRole('button', { name: '前往登录' })));
|
|
101
|
+
await waitFor(() => expect(mockTrace).toHaveBeenCalledTimes(2));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('有 OAuth client:停留 → 恢复态;「前往登录」以 force 跳过防重入重发起', async () => {
|
|
105
|
+
(window as any).__APP_CONFIG__ = { VITE_OAUTH_CLIENT_ID: 'portal-cid' };
|
|
106
|
+
mockState.machineStatus = 'unauthenticated';
|
|
107
|
+
renderGuard();
|
|
108
|
+
|
|
109
|
+
await waitFor(() => expect(mockInitiate).toHaveBeenCalledTimes(1));
|
|
110
|
+
const [cidArg, , opts] = mockInitiate.mock.calls[0];
|
|
111
|
+
expect(cidArg).toBe('portal-cid');
|
|
112
|
+
expect(opts.force).toBe(false);
|
|
113
|
+
expect(typeof opts.onStay).toBe('function');
|
|
114
|
+
|
|
115
|
+
act(() => opts.onStay());
|
|
116
|
+
expect(await screen.findByText('需要登录')).toBeInTheDocument();
|
|
117
|
+
|
|
118
|
+
act(() => fireEvent.click(screen.getByRole('button', { name: '前往登录' })));
|
|
119
|
+
await waitFor(() => expect(mockInitiate).toHaveBeenCalledTimes(2));
|
|
120
|
+
expect(mockInitiate.mock.calls[1][2]).toMatchObject({ force: true });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('未停留时保持原语义:unauthenticated 渲染 null(无恢复态)', async () => {
|
|
124
|
+
mockState.machineStatus = 'unauthenticated';
|
|
125
|
+
renderGuard();
|
|
126
|
+
|
|
127
|
+
await waitFor(() => expect(mockTrace).toHaveBeenCalledTimes(1));
|
|
128
|
+
expect(screen.queryByText('需要登录')).toBeNull();
|
|
129
|
+
expect(screen.queryByTestId('protected')).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
4
|
+
import { render, screen, waitFor, cleanup } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
// ============================================================
|
|
7
|
+
// L6(D9 / ADR-04):RequireAuth 的未登录出口
|
|
8
|
+
// - 能解析出 OAuth client → 起 PKCE(initiateOAuthLogin)
|
|
9
|
+
// - 不能解析 → 跳 auth 登录页,且**必须**带 from_requireauth=1
|
|
10
|
+
// (与 service.ts onUnauthorized 同口径:auth 侧先做会话复检,避免重登一次)
|
|
11
|
+
// ============================================================
|
|
12
|
+
|
|
13
|
+
const { mockState } = vi.hoisted(() => ({
|
|
14
|
+
mockState: {
|
|
15
|
+
machineStatus: 'unauthenticated' as string,
|
|
16
|
+
tenantRoute: {
|
|
17
|
+
slug: null as string | null,
|
|
18
|
+
tenantId: null as string | null,
|
|
19
|
+
oauthClientId: null as string | null,
|
|
20
|
+
loading: false,
|
|
21
|
+
notFound: true,
|
|
22
|
+
unknownSlug: false,
|
|
23
|
+
},
|
|
24
|
+
token: null as string | null,
|
|
25
|
+
replace: vi.fn(),
|
|
26
|
+
initiate: vi.fn(),
|
|
27
|
+
setBootstrapLock: vi.fn(),
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('../../auth/auth-machine', () => ({
|
|
32
|
+
useAuthMachine: () => ({ status: mockState.machineStatus }),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('../../auth/tenant-route-middleware', async (importOriginal) => ({
|
|
36
|
+
...(await importOriginal<typeof import('../../auth/tenant-route-middleware')>()),
|
|
37
|
+
useTenantRoute: () => mockState.tenantRoute,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('../../auth/oauth-login', () => ({
|
|
41
|
+
initiateOAuthLogin: (...args: any[]) => mockState.initiate(...args),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock('../../auth/service', () => ({
|
|
45
|
+
AuthService: {
|
|
46
|
+
getAccessToken: () => mockState.token,
|
|
47
|
+
getCurrentRole: () => null,
|
|
48
|
+
getPermissions: () => [],
|
|
49
|
+
},
|
|
50
|
+
setBootstrapLock: (...args: any[]) => mockState.setBootstrapLock(...args),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
import { RequireAuth } from '../RequireAuth';
|
|
54
|
+
import { getPortalUrl } from '../../config';
|
|
55
|
+
|
|
56
|
+
const originalWindowLocation = window.location;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 每例取全新模块实例:auth-trace 的模块级重入抑制(pending)与「停留」复位
|
|
60
|
+
* 是同页语义,跨用例泄漏会让后续 interstitial 被静默抑制(假红/假绿)。
|
|
61
|
+
*/
|
|
62
|
+
async function freshRequireAuth() {
|
|
63
|
+
vi.resetModules();
|
|
64
|
+
const mod = await import('../RequireAuth');
|
|
65
|
+
return mod.RequireAuth;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** authTrace 的提示条/确认页挂在 document.body(非 React 树),手动清理防跨用例残留。 */
|
|
69
|
+
function cleanupAuthTraceNodes() {
|
|
70
|
+
document.getElementById('auth-trace-notice')?.remove();
|
|
71
|
+
document.getElementById('auth-trace-confirm')?.remove();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function clickGoNow(): void {
|
|
75
|
+
const btn = Array.from(document.querySelectorAll<HTMLButtonElement>('#auth-trace-notice button')).find(
|
|
76
|
+
(b) => b.textContent === '立即前往',
|
|
77
|
+
);
|
|
78
|
+
expect(btn).toBeTruthy();
|
|
79
|
+
btn!.click();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
vi.clearAllMocks();
|
|
84
|
+
document.documentElement.lang = 'zh-CN';
|
|
85
|
+
mockState.machineStatus = 'unauthenticated';
|
|
86
|
+
mockState.tenantRoute = {
|
|
87
|
+
slug: null,
|
|
88
|
+
tenantId: null,
|
|
89
|
+
oauthClientId: null,
|
|
90
|
+
loading: false,
|
|
91
|
+
notFound: true,
|
|
92
|
+
unknownSlug: false,
|
|
93
|
+
};
|
|
94
|
+
mockState.token = null;
|
|
95
|
+
delete (window as any).__APP_CONFIG__;
|
|
96
|
+
|
|
97
|
+
delete (window as any).location;
|
|
98
|
+
(window as any).location = Object.defineProperties(
|
|
99
|
+
{},
|
|
100
|
+
{
|
|
101
|
+
...Object.getOwnPropertyDescriptors(originalWindowLocation),
|
|
102
|
+
replace: { get: () => mockState.replace },
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
if (window.location !== originalWindowLocation) {
|
|
109
|
+
Object.defineProperty(window, 'location', { value: originalWindowLocation, writable: true });
|
|
110
|
+
}
|
|
111
|
+
// vitest globals 未开启 ⇒ RTL 不会自动 cleanup,手动清理防跨用例 DOM 污染
|
|
112
|
+
cleanup();
|
|
113
|
+
cleanupAuthTraceNodes();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('RequireAuth 未登录出口', () => {
|
|
117
|
+
it('无可用 client_id → 提示条(可停留)后跳 auth 登录页并带 from_requireauth=1', async () => {
|
|
118
|
+
const RequireAuthFresh = await freshRequireAuth();
|
|
119
|
+
|
|
120
|
+
render(
|
|
121
|
+
<RequireAuthFresh>
|
|
122
|
+
<div>protected</div>
|
|
123
|
+
</RequireAuthFresh>,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// 层①:被动弹登录先出提示条(不再静默整页弹走)
|
|
127
|
+
await waitFor(() => expect(document.getElementById('auth-trace-notice')).toBeTruthy());
|
|
128
|
+
clickGoNow();
|
|
129
|
+
|
|
130
|
+
await waitFor(() => {
|
|
131
|
+
expect(mockState.replace).toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
const url = String(mockState.replace.mock.calls[0][0]);
|
|
134
|
+
expect(url).toContain('from_requireauth=1');
|
|
135
|
+
expect(url).toContain('redirect=');
|
|
136
|
+
expect(url).toContain('rt=localhost.unauthenticated.');
|
|
137
|
+
expect(url.startsWith(getPortalUrl('auth'))).toBe(true);
|
|
138
|
+
expect(mockState.initiate).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('slug 解析出 client_id → 发起 PKCE,不做登录页跳转', async () => {
|
|
142
|
+
mockState.tenantRoute = {
|
|
143
|
+
slug: 'demo',
|
|
144
|
+
tenantId: 't1',
|
|
145
|
+
oauthClientId: 'cid-from-slug',
|
|
146
|
+
loading: false,
|
|
147
|
+
notFound: false,
|
|
148
|
+
unknownSlug: false,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
render(
|
|
152
|
+
<RequireAuth>
|
|
153
|
+
<div>protected</div>
|
|
154
|
+
</RequireAuth>,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
await waitFor(() => {
|
|
158
|
+
// AUTH-03:第三参携「停留」恢复回调;effect 路径 force=false
|
|
159
|
+
expect(mockState.initiate).toHaveBeenCalledWith(
|
|
160
|
+
'cid-from-slug',
|
|
161
|
+
undefined,
|
|
162
|
+
expect.objectContaining({ force: false, onStay: expect.any(Function) }),
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
expect(mockState.replace).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('env 兜底 client_id(__APP_CONFIG__)→ 同样发起 PKCE', async () => {
|
|
169
|
+
(window as any).__APP_CONFIG__ = { VITE_OAUTH_CLIENT_ID: 'cid-from-env' };
|
|
170
|
+
|
|
171
|
+
render(
|
|
172
|
+
<RequireAuth>
|
|
173
|
+
<div>protected</div>
|
|
174
|
+
</RequireAuth>,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await waitFor(() => {
|
|
178
|
+
expect(mockState.initiate).toHaveBeenCalledWith(
|
|
179
|
+
'cid-from-env',
|
|
180
|
+
undefined,
|
|
181
|
+
expect.objectContaining({ force: false, onStay: expect.any(Function) }),
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
expect(mockState.replace).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('无 token + env client_id → 上 bootstrap 锁(抑制 401 抢跑)', async () => {
|
|
188
|
+
(window as any).__APP_CONFIG__ = { VITE_OAUTH_CLIENT_ID: 'cid-from-env' };
|
|
189
|
+
|
|
190
|
+
render(
|
|
191
|
+
<RequireAuth>
|
|
192
|
+
<div>protected</div>
|
|
193
|
+
</RequireAuth>,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
await waitFor(() => {
|
|
197
|
+
expect(mockState.setBootstrapLock).toHaveBeenCalledWith(true);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ============================================================
|
|
203
|
+
// F-W6 闸门(verification-W5-patch §6):未知 slug 死链 × auth 域有会话
|
|
204
|
+
// 曾构成无限整页往返(门户 → buildLoginUrl → auth 回跳 redirect → 门户 → …)。
|
|
205
|
+
// 修法:by-slug 确定性 404(unknownSlug)且本站未配 env client → 本地 404,不弹跳。
|
|
206
|
+
// 约束:网络错误/5xx 不置 unknownSlug(fail-open 仍走漏斗);env client 站点不触发。
|
|
207
|
+
// ============================================================
|
|
208
|
+
describe('RequireAuth F-W6 确定性 404 闸门', () => {
|
|
209
|
+
it('unknownSlug + 无 env client → 不发弹跳、渲染注入的 404', async () => {
|
|
210
|
+
mockState.tenantRoute = {
|
|
211
|
+
slug: null,
|
|
212
|
+
tenantId: null,
|
|
213
|
+
oauthClientId: null,
|
|
214
|
+
loading: false,
|
|
215
|
+
notFound: true,
|
|
216
|
+
unknownSlug: true,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
render(
|
|
220
|
+
<RequireAuth notFound={<div data-testid="tenant-404">no such tenant</div>}>
|
|
221
|
+
<div>protected</div>
|
|
222
|
+
</RequireAuth>,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
await waitFor(() => expect(screen.getByTestId('tenant-404')).toBeInTheDocument());
|
|
226
|
+
expect(mockState.replace).not.toHaveBeenCalled();
|
|
227
|
+
expect(mockState.initiate).not.toHaveBeenCalled();
|
|
228
|
+
expect(screen.queryByText('protected')).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('unknownSlug + 无 notFound 注入 → 渲染内置极简 404(不空白)', async () => {
|
|
232
|
+
mockState.tenantRoute = {
|
|
233
|
+
slug: null,
|
|
234
|
+
tenantId: null,
|
|
235
|
+
oauthClientId: null,
|
|
236
|
+
loading: false,
|
|
237
|
+
notFound: true,
|
|
238
|
+
unknownSlug: true,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
render(
|
|
242
|
+
<RequireAuth>
|
|
243
|
+
<div>protected</div>
|
|
244
|
+
</RequireAuth>,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
await waitFor(() => expect(screen.getByText('404')).toBeInTheDocument());
|
|
248
|
+
expect(mockState.replace).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('unknownSlug + env client(admin-console 旁路)→ 闸门不生效,仍走 PKCE', async () => {
|
|
252
|
+
(window as any).__APP_CONFIG__ = { VITE_OAUTH_CLIENT_ID: 'cid-from-env' };
|
|
253
|
+
mockState.tenantRoute = {
|
|
254
|
+
slug: null,
|
|
255
|
+
tenantId: null,
|
|
256
|
+
oauthClientId: null,
|
|
257
|
+
loading: false,
|
|
258
|
+
notFound: true,
|
|
259
|
+
unknownSlug: true,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
render(
|
|
263
|
+
<RequireAuth notFound={<div data-testid="tenant-404">no such tenant</div>}>
|
|
264
|
+
<div>protected</div>
|
|
265
|
+
</RequireAuth>,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
await waitFor(() =>
|
|
269
|
+
expect(mockState.initiate).toHaveBeenCalledWith(
|
|
270
|
+
'cid-from-env',
|
|
271
|
+
undefined,
|
|
272
|
+
expect.objectContaining({ force: false, onStay: expect.any(Function) }),
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
expect(screen.queryByTestId('tenant-404')).toBeNull();
|
|
276
|
+
expect(mockState.replace).not.toHaveBeenCalled();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('非 unknownSlug(网络错误 fail-open)→ 维持原漏斗(回归锁)', async () => {
|
|
280
|
+
// 默认 tenantRoute: notFound=true, unknownSlug=false —— 即 by-slug 网络错误场景
|
|
281
|
+
const RequireAuthFresh = await freshRequireAuth();
|
|
282
|
+
|
|
283
|
+
render(
|
|
284
|
+
<RequireAuthFresh notFound={<div data-testid="tenant-404">no such tenant</div>}>
|
|
285
|
+
<div>protected</div>
|
|
286
|
+
</RequireAuthFresh>,
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
await waitFor(() => expect(document.getElementById('auth-trace-notice')).toBeTruthy());
|
|
290
|
+
clickGoNow();
|
|
291
|
+
await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
|
|
292
|
+
const url = String(mockState.replace.mock.calls[0][0]);
|
|
293
|
+
expect(url).toContain('from_requireauth=1');
|
|
294
|
+
expect(screen.queryByTestId('tenant-404')).toBeNull();
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
4
|
+
import { render, screen, cleanup, waitFor } from '@testing-library/react';
|
|
5
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
6
|
+
import type { ReactNode } from 'react';
|
|
7
|
+
import { TenantIndexGuard, usePublicTenantSlugs } from '../TenantIndexGuard';
|
|
8
|
+
|
|
9
|
+
function createQueryClient() {
|
|
10
|
+
return new QueryClient({
|
|
11
|
+
defaultOptions: {
|
|
12
|
+
queries: { retry: false, gcTime: Infinity },
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function wrapper(children: ReactNode) {
|
|
18
|
+
return <QueryClientProvider client={createQueryClient()}>{children}</QueryClientProvider>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** mock fetch 返回 items 数组的 ListResponse */
|
|
22
|
+
function stubFetch(items: unknown[], ok = true, status = 200) {
|
|
23
|
+
vi.stubGlobal(
|
|
24
|
+
'fetch',
|
|
25
|
+
vi.fn().mockResolvedValue({
|
|
26
|
+
ok,
|
|
27
|
+
status,
|
|
28
|
+
json: async () => ({ code: 0, message: 'success', items, total: items.length }),
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stubFetchData(items: unknown[]) {
|
|
34
|
+
vi.stubGlobal(
|
|
35
|
+
'fetch',
|
|
36
|
+
vi.fn().mockResolvedValue({
|
|
37
|
+
ok: true,
|
|
38
|
+
status: 200,
|
|
39
|
+
json: async () => ({ code: 0, message: 'success', data: items }),
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stubFetchReject() {
|
|
45
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stubFetchNonOk() {
|
|
49
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stubFetchBadShape() {
|
|
53
|
+
vi.stubGlobal(
|
|
54
|
+
'fetch',
|
|
55
|
+
vi.fn().mockResolvedValue({
|
|
56
|
+
ok: true,
|
|
57
|
+
status: 200,
|
|
58
|
+
json: async () => ({ foo: 'bar' }),
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function setPath(path: string) {
|
|
64
|
+
window.history.pushState({}, '', path);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('usePublicTenantSlugs(响应契约解析)', () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
setPath('/acme-corp');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
cleanup();
|
|
74
|
+
vi.unstubAllGlobals();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('ListResponse 用 items 字段 → 返回租户数组(修复回归)', async () => {
|
|
78
|
+
stubFetch([
|
|
79
|
+
{ id: '1', name: 'acme-corp' },
|
|
80
|
+
{ id: '2', name: 'beta-inc' },
|
|
81
|
+
]);
|
|
82
|
+
let result: unknown[] | undefined;
|
|
83
|
+
function Harness() {
|
|
84
|
+
const q = usePublicTenantSlugs();
|
|
85
|
+
result = q.data;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
render(wrapper(<Harness />));
|
|
89
|
+
await waitFor(() =>
|
|
90
|
+
expect(result).toEqual([
|
|
91
|
+
{ id: '1', name: 'acme-corp' },
|
|
92
|
+
{ id: '2', name: 'beta-inc' },
|
|
93
|
+
]),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('旧 data 字段兼容 → 返回租户数组', async () => {
|
|
98
|
+
stubFetchData([{ id: '1', name: 'acme-corp' }]);
|
|
99
|
+
let result: unknown[] | undefined;
|
|
100
|
+
function Harness() {
|
|
101
|
+
const q = usePublicTenantSlugs();
|
|
102
|
+
result = q.data;
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
render(wrapper(<Harness />));
|
|
106
|
+
await waitFor(() => expect(result).toEqual([{ id: '1', name: 'acme-corp' }]));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('API 非 200 → 返回空数组(放行语义)', async () => {
|
|
110
|
+
stubFetchNonOk();
|
|
111
|
+
let result: unknown[] | undefined;
|
|
112
|
+
function Harness() {
|
|
113
|
+
const q = usePublicTenantSlugs();
|
|
114
|
+
result = q.data;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
render(wrapper(<Harness />));
|
|
118
|
+
await waitFor(() => expect(result).toEqual([]));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('网络异常 → 返回空数组', async () => {
|
|
122
|
+
stubFetchReject();
|
|
123
|
+
let result: unknown[] | undefined;
|
|
124
|
+
function Harness() {
|
|
125
|
+
const q = usePublicTenantSlugs();
|
|
126
|
+
result = q.data;
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
render(wrapper(<Harness />));
|
|
130
|
+
await waitFor(() => expect(result).toEqual([]));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('响应无 items/data 且非数组 → 返回空数组', async () => {
|
|
134
|
+
stubFetchBadShape();
|
|
135
|
+
let result: unknown[] | undefined;
|
|
136
|
+
function Harness() {
|
|
137
|
+
const q = usePublicTenantSlugs();
|
|
138
|
+
result = q.data;
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
render(wrapper(<Harness />));
|
|
142
|
+
await waitFor(() => expect(result).toEqual([]));
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('TenantIndexGuard(P0-2 slug 白名单守卫)', () => {
|
|
147
|
+
const children = <div data-testid="children">Home</div>;
|
|
148
|
+
const notFound = <div data-testid="notfound">404</div>;
|
|
149
|
+
|
|
150
|
+
beforeEach(() => {
|
|
151
|
+
setPath('/acme-corp');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
cleanup();
|
|
156
|
+
vi.unstubAllGlobals();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('slug 在白名单 → 渲染 children', async () => {
|
|
160
|
+
stubFetch([{ id: '1', name: 'acme-corp' }]);
|
|
161
|
+
render(wrapper(<TenantIndexGuard notFound={notFound}>{children}</TenantIndexGuard>));
|
|
162
|
+
await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument());
|
|
163
|
+
expect(screen.queryByTestId('notfound')).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('slug 不在白名单 → 渲染 notFound(修复回归:无效 slug 此前错误渲染首页)', async () => {
|
|
167
|
+
stubFetch([{ id: '1', name: 'acme-corp' }]);
|
|
168
|
+
setPath('/nonexistent-tenant');
|
|
169
|
+
render(wrapper(<TenantIndexGuard notFound={notFound}>{children}</TenantIndexGuard>));
|
|
170
|
+
await waitFor(() => expect(screen.getByTestId('notfound')).toBeInTheDocument());
|
|
171
|
+
expect(screen.queryByTestId('children')).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('白名单为空数组 → 放行渲染 children(API 挂掉时退化为不拦截)', async () => {
|
|
175
|
+
stubFetch([]);
|
|
176
|
+
setPath('/any-slug');
|
|
177
|
+
render(wrapper(<TenantIndexGuard notFound={notFound}>{children}</TenantIndexGuard>));
|
|
178
|
+
await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument());
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('URL 多段路径 → 直接 notFound(basename 已剥离 slug,内部必为未知段)', () => {
|
|
182
|
+
setPath('/acme-corp/xyz');
|
|
183
|
+
render(wrapper(<TenantIndexGuard notFound={notFound}>{children}</TenantIndexGuard>));
|
|
184
|
+
expect(screen.getByTestId('notfound')).toBeInTheDocument();
|
|
185
|
+
expect(screen.queryByTestId('children')).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('加载中 → 渲染 loading 占位', async () => {
|
|
189
|
+
let resolveFetch: (v: unknown) => void;
|
|
190
|
+
const pending = new Promise((res) => {
|
|
191
|
+
resolveFetch = res;
|
|
192
|
+
});
|
|
193
|
+
vi.stubGlobal('fetch', vi.fn().mockReturnValue(pending));
|
|
194
|
+
setPath('/acme-corp');
|
|
195
|
+
const loading = <div data-testid="loading">Loading...</div>;
|
|
196
|
+
render(
|
|
197
|
+
wrapper(
|
|
198
|
+
<TenantIndexGuard notFound={notFound} loading={loading}>
|
|
199
|
+
{children}
|
|
200
|
+
</TenantIndexGuard>,
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
expect(screen.getByTestId('loading')).toBeInTheDocument();
|
|
204
|
+
await resolveFetch!({
|
|
205
|
+
ok: true,
|
|
206
|
+
status: 200,
|
|
207
|
+
json: async () => ({ code: 0, items: [{ id: '1', name: 'acme-corp' }] }),
|
|
208
|
+
});
|
|
209
|
+
await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument());
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('空 slug(根路径 /)→ 渲染 children', async () => {
|
|
213
|
+
stubFetch([{ id: '1', name: 'acme-corp' }]);
|
|
214
|
+
setPath('/');
|
|
215
|
+
render(wrapper(<TenantIndexGuard notFound={notFound}>{children}</TenantIndexGuard>));
|
|
216
|
+
await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument());
|
|
217
|
+
});
|
|
218
|
+
});
|