@autional/shared 0.0.0-stage → 0.1.0-rc.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,185 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// 回归(2026-10-03 线上四门户「未登录」):oauth 服务的 id_token 只含 sub 等标准
|
|
5
|
+
// claim(无 email/username)。修复前 parseUserFromToken(id_token) 的真值结果短路了
|
|
6
|
+
// /auth/me 兜底,store 落残缺 user → UserMenu 显示「未登录」。
|
|
7
|
+
// 另:登录时固定调一次 OIDC userinfo(/oauth/api/v1/oauth/userinfo)合并展示名
|
|
8
|
+
// (nickname→name)+ picture;失败静默降级,不阻断登录。
|
|
9
|
+
|
|
10
|
+
const { mockPost, mockGet, mockLoginWithTokens, mockUpdateCurrentTenant, mockPersistCid, mockTrace } =
|
|
11
|
+
vi.hoisted(() => ({
|
|
12
|
+
mockPost: vi.fn(),
|
|
13
|
+
mockGet: vi.fn(),
|
|
14
|
+
mockLoginWithTokens: vi.fn(),
|
|
15
|
+
mockUpdateCurrentTenant: vi.fn(),
|
|
16
|
+
mockPersistCid: vi.fn(),
|
|
17
|
+
mockTrace: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('../../api/client', () => ({ apiClient: { post: mockPost, get: mockGet } }));
|
|
21
|
+
vi.mock('../store', () => ({ loginWithTokens: mockLoginWithTokens }));
|
|
22
|
+
vi.mock('../service', () => ({ AuthService: { updateCurrentTenant: mockUpdateCurrentTenant } }));
|
|
23
|
+
vi.mock('../oauth-client-id-store', () => ({ persistOAuthClientId: mockPersistCid }));
|
|
24
|
+
vi.mock('../auth-trace', () => ({ traceRedirect: mockTrace }));
|
|
25
|
+
vi.mock('../../config', () => ({ getPortalUrl: () => 'https://auth.example.com' }));
|
|
26
|
+
|
|
27
|
+
import { handleOAuthCallback } from '../oauth-login';
|
|
28
|
+
|
|
29
|
+
function jwt(payload: Record<string, unknown>): string {
|
|
30
|
+
return `h.${btoa(JSON.stringify(payload))}.s`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ACCESS_TOKEN = jwt({ sub: 'u-1', tenant_id: 'tenant-1' });
|
|
34
|
+
const ME_URL = '/identity/api/v1/auth/me';
|
|
35
|
+
const USERINFO_URL = '/oauth/api/v1/oauth/userinfo';
|
|
36
|
+
const ME_USER = {
|
|
37
|
+
id: 'u-1',
|
|
38
|
+
username: '',
|
|
39
|
+
email: 'demo-admin@demo.localhost',
|
|
40
|
+
status: 'active',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function getUserCalls(url: string) {
|
|
44
|
+
return mockGet.mock.calls.filter((c) => String(c[0]) === url);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
sessionStorage.clear();
|
|
50
|
+
window.history.replaceState({}, '', '/?code=test-code');
|
|
51
|
+
sessionStorage.setItem('oauth_pkce_verifier', 'verifier-1');
|
|
52
|
+
sessionStorage.setItem('oauth_client_id', 'cid-1');
|
|
53
|
+
mockPost.mockResolvedValue({
|
|
54
|
+
data: { access_token: ACCESS_TOKEN, refresh_token: 'rt-1', id_token: jwt({ sub: 'u-1' }) },
|
|
55
|
+
});
|
|
56
|
+
// 默认:/auth/me 返回完整 user;userinfo 返回空(展示名缺省,走回落链)
|
|
57
|
+
mockGet.mockImplementation((url: string) =>
|
|
58
|
+
String(url) === USERINFO_URL
|
|
59
|
+
? Promise.resolve({ data: {} })
|
|
60
|
+
: Promise.resolve({ data: { ...ME_USER } }),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('handleOAuthCallback 用户信息解析(id_token 缺身份字段回归)', () => {
|
|
65
|
+
it('id_token 只含 sub → 继续调 /auth/me,落完整 user(不再显示未登录)', async () => {
|
|
66
|
+
await handleOAuthCallback();
|
|
67
|
+
|
|
68
|
+
const meCalls = getUserCalls(ME_URL);
|
|
69
|
+
expect(meCalls).toHaveLength(1);
|
|
70
|
+
expect(meCalls[0][1]).toMatchObject({
|
|
71
|
+
headers: { Authorization: `Bearer ${ACCESS_TOKEN}` },
|
|
72
|
+
});
|
|
73
|
+
expect(mockLoginWithTokens).toHaveBeenCalledTimes(1);
|
|
74
|
+
const user = mockLoginWithTokens.mock.calls[0][2];
|
|
75
|
+
expect(user.email).toBe('demo-admin@demo.localhost');
|
|
76
|
+
expect(user.id).toBe('u-1');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('id_token 含 email → 直接采信,不调 /auth/me(既有偏好保持)', async () => {
|
|
80
|
+
mockPost.mockResolvedValue({
|
|
81
|
+
data: {
|
|
82
|
+
access_token: ACCESS_TOKEN,
|
|
83
|
+
refresh_token: 'rt-1',
|
|
84
|
+
id_token: jwt({ sub: 'u-1', email: 'a@b.c' }),
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await handleOAuthCallback();
|
|
89
|
+
|
|
90
|
+
expect(getUserCalls(ME_URL)).toHaveLength(0);
|
|
91
|
+
expect(mockLoginWithTokens.mock.calls[0][2].email).toBe('a@b.c');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('id_token 缺字段且 /auth/me 失败 → access_token JWT 兜底(id 可用)', async () => {
|
|
95
|
+
mockGet.mockImplementation((url: string) =>
|
|
96
|
+
String(url) === ME_URL
|
|
97
|
+
? Promise.reject(new Error('me down'))
|
|
98
|
+
: Promise.resolve({ data: {} }),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
await handleOAuthCallback();
|
|
102
|
+
|
|
103
|
+
expect(mockLoginWithTokens).toHaveBeenCalledTimes(1);
|
|
104
|
+
expect(mockLoginWithTokens.mock.calls[0][2].id).toBe('u-1');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('id_token 缺失 → 同样落 /auth/me', async () => {
|
|
108
|
+
mockPost.mockResolvedValue({
|
|
109
|
+
data: { access_token: ACCESS_TOKEN, refresh_token: 'rt-1' },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await handleOAuthCallback();
|
|
113
|
+
|
|
114
|
+
expect(getUserCalls(ME_URL)).toHaveLength(1);
|
|
115
|
+
expect(mockLoginWithTokens.mock.calls[0][2].email).toBe('demo-admin@demo.localhost');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('handleOAuthCallback userinfo 展示名合并', () => {
|
|
120
|
+
it('每次登录固定调一次 userinfo,带显式 Bearer(拦截器尚无 token)', async () => {
|
|
121
|
+
await handleOAuthCallback();
|
|
122
|
+
|
|
123
|
+
const calls = getUserCalls(USERINFO_URL);
|
|
124
|
+
expect(calls).toHaveLength(1);
|
|
125
|
+
expect(calls[0][1]).toMatchObject({
|
|
126
|
+
headers: { Authorization: `Bearer ${ACCESS_TOKEN}` },
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('id_token 含 email(短路 /auth/me)时 userinfo 仍调用——id_token 永不含展示名', async () => {
|
|
131
|
+
mockPost.mockResolvedValue({
|
|
132
|
+
data: {
|
|
133
|
+
access_token: ACCESS_TOKEN,
|
|
134
|
+
refresh_token: 'rt-1',
|
|
135
|
+
id_token: jwt({ sub: 'u-1', email: 'a@b.c' }),
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
mockGet.mockImplementation(() => Promise.resolve({ data: { name: 'Demo Admin' } }));
|
|
139
|
+
|
|
140
|
+
await handleOAuthCallback();
|
|
141
|
+
|
|
142
|
+
expect(mockLoginWithTokens.mock.calls[0][2].displayName).toBe('Demo Admin');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('nickname 合入 displayName(优先于 name,含 trim)', async () => {
|
|
146
|
+
mockGet.mockImplementation((url: string) =>
|
|
147
|
+
String(url) === USERINFO_URL
|
|
148
|
+
? Promise.resolve({ data: { nickname: ' Demo Admin ', name: 'Demo A. Admin' } })
|
|
149
|
+
: Promise.resolve({ data: { ...ME_USER } }),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
await handleOAuthCallback();
|
|
153
|
+
|
|
154
|
+
expect(mockLoginWithTokens.mock.calls[0][2].displayName).toBe('Demo Admin');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('无 nickname 时回落 name;picture 合入 avatarUrl', async () => {
|
|
158
|
+
mockGet.mockImplementation((url: string) =>
|
|
159
|
+
String(url) === USERINFO_URL
|
|
160
|
+
? Promise.resolve({ data: { name: 'Demo Admin', picture: 'https://x/p.png' } })
|
|
161
|
+
: Promise.resolve({ data: { ...ME_USER } }),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
await handleOAuthCallback();
|
|
165
|
+
|
|
166
|
+
const user = mockLoginWithTokens.mock.calls[0][2];
|
|
167
|
+
expect(user.displayName).toBe('Demo Admin');
|
|
168
|
+
expect(user.avatarUrl).toBe('https://x/p.png');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('userinfo 失败 → 登录继续,displayName 缺省(消费侧回落 username/email)', async () => {
|
|
172
|
+
mockGet.mockImplementation((url: string) =>
|
|
173
|
+
String(url) === USERINFO_URL
|
|
174
|
+
? Promise.reject(new Error('userinfo down'))
|
|
175
|
+
: Promise.resolve({ data: { ...ME_USER } }),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
await handleOAuthCallback();
|
|
179
|
+
|
|
180
|
+
expect(mockLoginWithTokens).toHaveBeenCalledTimes(1);
|
|
181
|
+
const user = mockLoginWithTokens.mock.calls[0][2];
|
|
182
|
+
expect(user.displayName).toBeUndefined();
|
|
183
|
+
expect(user.email).toBe('demo-admin@demo.localhost');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generatePKCE } from '../pkce';
|
|
3
|
+
|
|
4
|
+
// RFC 7636 verifier charset — must stay in sync with generateRandomString in ../pkce.ts
|
|
5
|
+
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
|
6
|
+
const CHAR_SET = new Set(CHARSET.split(''));
|
|
7
|
+
|
|
8
|
+
describe('generatePKCE (PRNG rejection sampling — no modulo bias)', () => {
|
|
9
|
+
it('produces RFC 7636-compliant verifier: 64 chars from A-Za-z0-9-._~', async () => {
|
|
10
|
+
for (let i = 0; i < 50; i++) {
|
|
11
|
+
const { verifier } = await generatePKCE();
|
|
12
|
+
expect(verifier).toHaveLength(64);
|
|
13
|
+
for (const ch of verifier) {
|
|
14
|
+
expect(CHAR_SET.has(ch), `unexpected char ${ch}`).toBe(true);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('verifiers differ across calls (non-deterministic)', async () => {
|
|
20
|
+
const a = await generatePKCE();
|
|
21
|
+
const b = await generatePKCE();
|
|
22
|
+
expect(a.verifier).not.toBe(b.verifier);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('each of the 66 chars is near-uniformly distributed (rejection sampling kills modulo bias)', async () => {
|
|
26
|
+
// 30k verifiers × 64 chars = 1.92M samples. With uniform distribution the
|
|
27
|
+
// per-char std dev is ~0.9% of the mean, so ±5% tolerance has ~7σ margin —
|
|
28
|
+
// never flakes, while a naive `byte % 66` would push chars 58-65 ~22%
|
|
29
|
+
// below the mean and be caught immediately.
|
|
30
|
+
const SAMPLES = 30_000;
|
|
31
|
+
const counts = new Array<number>(66).fill(0);
|
|
32
|
+
for (let i = 0; i < SAMPLES; i++) {
|
|
33
|
+
const { verifier } = await generatePKCE();
|
|
34
|
+
for (const ch of verifier) {
|
|
35
|
+
counts[CHARSET.indexOf(ch)]++;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const total = SAMPLES * 64;
|
|
39
|
+
const expectedFreq = 1 / 66;
|
|
40
|
+
for (let i = 0; i < 66; i++) {
|
|
41
|
+
const freq = counts[i] / total;
|
|
42
|
+
expect(
|
|
43
|
+
Math.abs(freq - expectedFreq),
|
|
44
|
+
`char ${CHARSET[i]} deviated ${(((freq - expectedFreq) / expectedFreq) * 100).toFixed(2)}% from uniform`,
|
|
45
|
+
).toBeLessThan(0.05 * expectedFreq);
|
|
46
|
+
}
|
|
47
|
+
}, 30_000);
|
|
48
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// ============================================================
|
|
5
|
+
// L6(D8):刷新分流与登出吊销的 client_id 来源
|
|
6
|
+
// - 会话内已解析值(localStorage `oauth_client_id`,OAuth 交棒时落库)优先
|
|
7
|
+
// - env(__APP_CONFIG__.VITE_OAUTH_CLIENT_ID)兜底
|
|
8
|
+
// - 两者皆无:保持旧行为(落 identity 腿)
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
const { mockPost } = vi.hoisted(() => ({ mockPost: vi.fn() }));
|
|
12
|
+
|
|
13
|
+
vi.mock('axios', () => ({ default: { post: mockPost } }));
|
|
14
|
+
vi.mock('../../api/bff-client', () => ({ bffLogout: vi.fn(() => Promise.resolve()) }));
|
|
15
|
+
|
|
16
|
+
import { AuthService } from '../service';
|
|
17
|
+
import { useAuthStore } from '../store';
|
|
18
|
+
import { persistOAuthClientId } from '../oauth-client-id-store';
|
|
19
|
+
|
|
20
|
+
const OAUTH_REFRESH = '/oauth/api/v1/oauth/refresh';
|
|
21
|
+
const IDENTITY_REFRESH = '/identity/api/v1/auth/refresh';
|
|
22
|
+
const REVOKE = '/oauth/api/v1/oauth/revoke';
|
|
23
|
+
|
|
24
|
+
function callsTo(fragment: string) {
|
|
25
|
+
return mockPost.mock.calls.filter((c) => String(c[0]).includes(fragment));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
localStorage.clear();
|
|
31
|
+
useAuthStore.setState({ accessToken: null, refreshToken: null, isAuthenticated: false });
|
|
32
|
+
delete (window as any).__APP_CONFIG__;
|
|
33
|
+
mockPost.mockResolvedValue({ data: { access_token: 'new-at', refresh_token: 'rt-new' } });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.unstubAllGlobals();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('AuthService.refreshToken 分流', () => {
|
|
41
|
+
it('opaque refresh token + 会话内 client_id → 走 OAuth 腿并带上该 client_id', async () => {
|
|
42
|
+
persistOAuthClientId('cid-from-session');
|
|
43
|
+
useAuthStore.setState({ refreshToken: 'rt-opaque-abc' });
|
|
44
|
+
|
|
45
|
+
const at = await AuthService.refreshToken();
|
|
46
|
+
|
|
47
|
+
expect(at).toBe('new-at');
|
|
48
|
+
const oauthCalls = callsTo(OAUTH_REFRESH);
|
|
49
|
+
expect(oauthCalls).toHaveLength(1);
|
|
50
|
+
expect(oauthCalls[0][1]).toEqual({
|
|
51
|
+
refresh_token: 'rt-opaque-abc',
|
|
52
|
+
client_id: 'cid-from-session',
|
|
53
|
+
});
|
|
54
|
+
expect(callsTo(IDENTITY_REFRESH)).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('无会话值时 env 兜底(VITE_OAUTH_CLIENT_ID)', async () => {
|
|
58
|
+
(window as any).__APP_CONFIG__ = { VITE_OAUTH_CLIENT_ID: 'cid-from-env' };
|
|
59
|
+
useAuthStore.setState({ refreshToken: 'rt-opaque-abc' });
|
|
60
|
+
|
|
61
|
+
await AuthService.refreshToken();
|
|
62
|
+
|
|
63
|
+
expect(callsTo(OAUTH_REFRESH)[0][1]).toEqual({
|
|
64
|
+
refresh_token: 'rt-opaque-abc',
|
|
65
|
+
client_id: 'cid-from-env',
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('两者皆无 → 落 identity 腿(形态与接线前一致)', async () => {
|
|
70
|
+
useAuthStore.setState({ refreshToken: 'rt-opaque-abc' });
|
|
71
|
+
|
|
72
|
+
await AuthService.refreshToken();
|
|
73
|
+
|
|
74
|
+
expect(callsTo(IDENTITY_REFRESH)).toHaveLength(1);
|
|
75
|
+
expect(callsTo(OAUTH_REFRESH)).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('JWT refresh token(三段式)恒走 identity 腿,即使会话内有 client_id', async () => {
|
|
79
|
+
persistOAuthClientId('cid-from-session');
|
|
80
|
+
useAuthStore.setState({ refreshToken: 'a.b.c' });
|
|
81
|
+
|
|
82
|
+
await AuthService.refreshToken();
|
|
83
|
+
|
|
84
|
+
expect(callsTo(IDENTITY_REFRESH)).toHaveLength(1);
|
|
85
|
+
expect(callsTo(OAUTH_REFRESH)).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// 回归锁(2026-10-03 线上缺陷:identity 腿响应是标准信封 {code,message,data:{...}},
|
|
89
|
+
// 客户端却按扁平解析 ⇒ data.access_token 恒 undefined ⇒ updateTokens 从不执行 ⇒
|
|
90
|
+
// 预警点横幅 + 到期拿旧 RT 重放 ⇒ 服务端反重放 revoke-all 全量吊销会话)。
|
|
91
|
+
// 此前测试用扁平 mock 喂两条腿掩盖了信封形状差异,此处锁真实信封。
|
|
92
|
+
it('identity 腿(三段式 RT)信封响应必须解包:token 落库 + 返回新 AT', async () => {
|
|
93
|
+
useAuthStore.setState({ refreshToken: 'a.b.c' });
|
|
94
|
+
mockPost.mockResolvedValue({
|
|
95
|
+
data: {
|
|
96
|
+
code: 0,
|
|
97
|
+
message: 'success',
|
|
98
|
+
data: { access_token: 'env-at', refresh_token: 'env-rt' },
|
|
99
|
+
timestamp: 1728000000000,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const at = await AuthService.refreshToken();
|
|
104
|
+
|
|
105
|
+
expect(callsTo(IDENTITY_REFRESH)).toHaveLength(1);
|
|
106
|
+
expect(at).toBe('env-at');
|
|
107
|
+
expect(useAuthStore.getState().accessToken).toBe('env-at');
|
|
108
|
+
expect(useAuthStore.getState().refreshToken).toBe('env-rt');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('刷新成功后新 token 落库(updateTokens 链路)', async () => {
|
|
112
|
+
persistOAuthClientId('cid-from-session');
|
|
113
|
+
useAuthStore.setState({ refreshToken: 'rt-opaque-abc' });
|
|
114
|
+
|
|
115
|
+
await AuthService.refreshToken();
|
|
116
|
+
|
|
117
|
+
expect(useAuthStore.getState().accessToken).toBe('new-at');
|
|
118
|
+
expect(useAuthStore.getState().refreshToken).toBe('rt-new');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('AuthService.logout 吊销', () => {
|
|
123
|
+
it('access/refresh 两条吊销都带会话内 client_id', async () => {
|
|
124
|
+
persistOAuthClientId('cid-from-session');
|
|
125
|
+
useAuthStore.setState({ accessToken: 'at-1', refreshToken: 'rt-opaque-1' });
|
|
126
|
+
|
|
127
|
+
await AuthService.logout();
|
|
128
|
+
|
|
129
|
+
const revokes = callsTo(REVOKE);
|
|
130
|
+
expect(revokes).toHaveLength(2);
|
|
131
|
+
for (const call of revokes) {
|
|
132
|
+
expect(String(call[1])).toContain('client_id=cid-from-session');
|
|
133
|
+
}
|
|
134
|
+
expect(String(revokes[0][1])).toContain('token_type_hint=access_token');
|
|
135
|
+
expect(String(revokes[1][1])).toContain('token_type_hint=refresh_token');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('登出清 token 但**不清** client_id(部署级取值,非用户私有数据)', async () => {
|
|
139
|
+
persistOAuthClientId('cid-from-session');
|
|
140
|
+
useAuthStore.setState({ accessToken: 'at-1', refreshToken: 'rt-opaque-1' });
|
|
141
|
+
|
|
142
|
+
await AuthService.logout();
|
|
143
|
+
|
|
144
|
+
expect(localStorage.getItem('oauth_client_id')).toBe('cid-from-session');
|
|
145
|
+
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { extractSlugFromPath, registerNonTenantSegments, clearNonTenantSegments } from '../slug-from-url';
|
|
3
|
+
|
|
4
|
+
describe('extractSlugFromPath', () => {
|
|
5
|
+
describe('tenant slug patterns', () => {
|
|
6
|
+
it('extracts slug from /{slug}/admin', () => {
|
|
7
|
+
expect(extractSlugFromPath('/acme/admin')).toBe('acme');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('extracts slug from /{slug}/developer', () => {
|
|
11
|
+
expect(extractSlugFromPath('/foo/developer')).toBe('foo');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('extracts slug from /{slug}/security', () => {
|
|
15
|
+
expect(extractSlugFromPath('/bar/security')).toBe('bar');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('extracts slug from /{slug}/admin/dashboard (3 segments)', () => {
|
|
19
|
+
expect(extractSlugFromPath('/mycorp/admin/dashboard')).toBe('mycorp');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('extracts slug with dashes and numbers', () => {
|
|
23
|
+
expect(extractSlugFromPath('/acme-corp-123/admin')).toBe('acme-corp-123');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('extracts slug when followed by portal prefix in path with extra segments', () => {
|
|
27
|
+
expect(extractSlugFromPath('/tenant1/admin/users/123')).toBe('tenant1');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('no slug — returns undefined', () => {
|
|
32
|
+
it('returns undefined for root /', () => {
|
|
33
|
+
expect(extractSlugFromPath('/')).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns admin as slug candidate for /admin (no root portal prefix after domain migration)', () => {
|
|
37
|
+
expect(extractSlugFromPath('/admin')).toBe('admin');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns developer as slug candidate (no longer a root portal after domain migration)', () => {
|
|
41
|
+
// ADR-005: each portal has its own domain, /developer is no longer a root portal prefix
|
|
42
|
+
expect(extractSlugFromPath('/developer')).toBe('developer');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns undefined for reserved paths', () => {
|
|
46
|
+
expect(extractSlugFromPath('/oauth/callback')).toBeUndefined();
|
|
47
|
+
expect(extractSlugFromPath('/bff/identity/api/v1/me')).toBeUndefined();
|
|
48
|
+
expect(extractSlugFromPath('/api/v1/health')).toBeUndefined();
|
|
49
|
+
expect(extractSlugFromPath('/assets/logo.png')).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns admin as slug candidate for /admin/users (domain migration: no portal prefix at root)', () => {
|
|
53
|
+
// ADR-005: each portal has its own domain, so /{slug}/users pattern is expected
|
|
54
|
+
expect(extractSlugFromPath('/admin/users')).toBe('admin');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('edge cases', () => {
|
|
59
|
+
it('handles empty string', () => {
|
|
60
|
+
expect(extractSlugFromPath('')).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('handles trailing slash', () => {
|
|
64
|
+
expect(extractSlugFromPath('/acme/admin/')).toBe('acme');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns undefined for reserved-first segment even with portal second', () => {
|
|
68
|
+
// oauth is reserved, so /oauth/admin has no valid slug
|
|
69
|
+
expect(extractSlugFromPath('/oauth/admin')).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns admin as slug candidate for /admin/settings (no portal prefix at root after domain migration)', () => {
|
|
73
|
+
// ADR-005: independent domains — no root portal prefixes
|
|
74
|
+
expect(extractSlugFromPath('/admin/settings')).toBe('admin');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('registerNonTenantSegments —— 各 portal 声明自己的业务路由', () => {
|
|
78
|
+
afterEach(() => clearNonTenantSegments());
|
|
79
|
+
|
|
80
|
+
it('未注册时行为与改动前一致(向后兼容,9 个应用无需同时改)', () => {
|
|
81
|
+
expect(extractSlugFromPath('/settings')).toBe('settings');
|
|
82
|
+
expect(extractSlugFromPath('/users/123')).toBe('users');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('注册后业务路由不再被当成租户', () => {
|
|
86
|
+
registerNonTenantSegments(['settings', 'users', 'dashboard']);
|
|
87
|
+
expect(extractSlugFromPath('/settings')).toBeUndefined();
|
|
88
|
+
expect(extractSlugFromPath('/users/123')).toBeUndefined();
|
|
89
|
+
expect(extractSlugFromPath('/dashboard')).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('注册后真实租户仍能识别 —— 这是本机制的关键不变量', () => {
|
|
93
|
+
registerNonTenantSegments(['settings', 'dashboard']);
|
|
94
|
+
expect(extractSlugFromPath('/acme')).toBe('acme');
|
|
95
|
+
expect(extractSlugFromPath('/acme/dashboard')).toBe('acme');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('大小写不敏感,且容忍前后斜杠与空项', () => {
|
|
99
|
+
registerNonTenantSegments([' Settings ', '/MFA-Challenge/', '', 'sso']);
|
|
100
|
+
expect(extractSlugFromPath('/settings')).toBeUndefined();
|
|
101
|
+
expect(extractSlugFromPath('/mfa-challenge/x')).toBeUndefined();
|
|
102
|
+
expect(extractSlugFromPath('/sso')).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('clearNonTenantSegments 可恢复(测试之间不串味)', () => {
|
|
106
|
+
registerNonTenantSegments(['settings']);
|
|
107
|
+
expect(extractSlugFromPath('/settings')).toBeUndefined();
|
|
108
|
+
clearNonTenantSegments();
|
|
109
|
+
expect(extractSlugFromPath('/settings')).toBe('settings');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|