@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,49 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// PL-70 回归锁:JWT 段是 base64url(`-`/`_` 替代 `+`/`/`、无填充),裸 atob 对含 `-`/`_`
|
|
3
|
+
// 的载荷抛 InvalidCharacterError → store.isTokenExpired 误判「已过期」→ 请求拦截器
|
|
4
|
+
// 预判式刷新 → 无 RT 的模拟会话被清会话弹登录。旧套件用 btoa 直造「标准 base64」载荷,
|
|
5
|
+
// 恰好覆盖不到此形态(preflight-refresh.test.ts 的 expiredJwt 即此盲区)。
|
|
6
|
+
import { describe, it, expect } from 'vitest';
|
|
7
|
+
import { decodeJwtPayload } from '../jwt-payload';
|
|
8
|
+
import { isTokenExpired } from '../store';
|
|
9
|
+
|
|
10
|
+
// 造 base64url 载荷段。pad 连排 9 个 '?'(0x3F):任意连续 3 个字节位置中恰有一个
|
|
11
|
+
// 全局下标 ≡2 (mod 3),其 6bit 组 = 低 6 位 111111 → base64 字符 63 → base64url `_`
|
|
12
|
+
// ——保证段内定产 `_`,用例真实覆盖 base64url 形态而非碰运气。
|
|
13
|
+
function makeToken(payload: Record<string, unknown>): { token: string; segment: string } {
|
|
14
|
+
const json = JSON.stringify({ ...payload, pad: '?????????' });
|
|
15
|
+
let binary = '';
|
|
16
|
+
for (const b of Array.from(Buffer.from(json, 'utf8'))) binary += String.fromCharCode(b);
|
|
17
|
+
const segment = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
18
|
+
return { token: `eyJhbGciOiJIUzI1NiJ9.${segment}.sig`, segment };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('decodeJwtPayload(base64url 归一化)', () => {
|
|
22
|
+
it('含 -/_ 的 base64url 载荷可解,且非 ASCII 正确还原', () => {
|
|
23
|
+
const { token, segment } = makeToken({ exp: 4102444800, n: 'ÿ中文' });
|
|
24
|
+
expect(segment).toMatch(/[-_]/);
|
|
25
|
+
const payload = decodeJwtPayload(token);
|
|
26
|
+
expect(payload?.n).toBe('ÿ中文');
|
|
27
|
+
expect(payload?.exp).toBe(4102444800);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('畸形输入 → null(不抛)', () => {
|
|
31
|
+
expect(decodeJwtPayload('not-a-jwt')).toBeNull();
|
|
32
|
+
expect(decodeJwtPayload('a.b')).toBeNull();
|
|
33
|
+
expect(decodeJwtPayload('a.!!!.c')).toBeNull();
|
|
34
|
+
expect(decodeJwtPayload('a.' + btoa('not json') + '.c')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('isTokenExpired:base64url 载荷 + 未来 exp → false(旧实现误判 true 的事故形态)', () => {
|
|
38
|
+
const exp = Math.floor(Date.now() / 1000) + 3600;
|
|
39
|
+
const { token, segment } = makeToken({ exp, n: 'ÿ' });
|
|
40
|
+
expect(segment).toMatch(/[-_]/);
|
|
41
|
+
expect(isTokenExpired(token)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('isTokenExpired:过期 exp → true;畸形 token → true', () => {
|
|
45
|
+
const { token } = makeToken({ exp: Math.floor(Date.now() / 1000) - 60, n: 'ÿ' });
|
|
46
|
+
expect(isTokenExpired(token)).toBe(true);
|
|
47
|
+
expect(isTokenExpired('not-a-jwt')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// U350 回归锁:logout(redirectTo) 接通导航语义 —— 先清会话(await AuthService.logout()),
|
|
3
|
+
// 再整页前往(traceRedirect assign);无参调用保持「仅清会话、导航交调用方」。
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
5
|
+
|
|
6
|
+
const { logoutSpy, traceRedirectSpy } = vi.hoisted(() => ({
|
|
7
|
+
logoutSpy: vi.fn(async () => {}),
|
|
8
|
+
traceRedirectSpy: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('../service', () => ({
|
|
12
|
+
AuthService: { logout: logoutSpy },
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('../auth-trace', () => ({
|
|
15
|
+
traceRedirect: traceRedirectSpy,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { logout } from '../store';
|
|
19
|
+
|
|
20
|
+
describe('U350: shared logout(redirectTo) 导航语义', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
logoutSpy.mockClear();
|
|
23
|
+
traceRedirectSpy.mockClear();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('带 redirectTo:先清会话,再整页前往(reason=logout, assign)', async () => {
|
|
27
|
+
await logout('/auth/acme-corp/login?account_deleted=true');
|
|
28
|
+
expect(logoutSpy).toHaveBeenCalledTimes(1);
|
|
29
|
+
expect(traceRedirectSpy).toHaveBeenCalledWith(
|
|
30
|
+
'/auth/acme-corp/login?account_deleted=true',
|
|
31
|
+
{ reason: 'logout', assign: true },
|
|
32
|
+
);
|
|
33
|
+
// 顺序锁:导航必须发生在会话清理之后(先终结会话再离开)
|
|
34
|
+
expect(logoutSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
|
35
|
+
traceRedirectSpy.mock.invocationCallOrder[0],
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('无参:仅清会话、不导航(程序性调用方不受影响)', async () => {
|
|
40
|
+
await logout();
|
|
41
|
+
expect(logoutSpy).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(traceRedirectSpy).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// ============================================================
|
|
3
|
+
// AUTH-15:登出吊销分链(identity vs OAuth PKCE)——
|
|
4
|
+
// ① OAuth 链(不透明 rt + client_id)→ oauth revoke ×2(at/rt)带 client_id
|
|
5
|
+
// ② identity 链(JWT rt / 无 client_id)→ identity /auth/logout 携 Bearer AT
|
|
6
|
+
// ③ JWT rt + 持久 client_id 仍走 identity(镜像 refreshToken 分链判据)
|
|
7
|
+
// ④ AUTH-08 登出卫生:localStorage 四键 + OAuth 预置物 + trace 缓冲
|
|
8
|
+
// ⑤ 吊销失败 → trace 留痕 failed(HTTP status / network-error)且 logout 仍 resolve
|
|
9
|
+
// ============================================================
|
|
10
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
11
|
+
|
|
12
|
+
const { mockCtx, mockClearAuth, mockTraceEvent, mockTraceClear, mockDiscard, mockBffLogout, mockPost } =
|
|
13
|
+
vi.hoisted(() => ({
|
|
14
|
+
mockCtx: {
|
|
15
|
+
accessToken: null as string | null,
|
|
16
|
+
refreshToken: null as string | null,
|
|
17
|
+
clientId: '',
|
|
18
|
+
},
|
|
19
|
+
mockClearAuth: vi.fn(),
|
|
20
|
+
mockTraceEvent: vi.fn(),
|
|
21
|
+
mockTraceClear: vi.fn(),
|
|
22
|
+
mockDiscard: vi.fn(),
|
|
23
|
+
mockBffLogout: vi.fn(),
|
|
24
|
+
mockPost: vi.fn(),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock('../store', () => ({
|
|
28
|
+
useAuthStore: {
|
|
29
|
+
getState: () => ({
|
|
30
|
+
clearAuth: mockClearAuth,
|
|
31
|
+
user: null,
|
|
32
|
+
isAuthenticated: false,
|
|
33
|
+
currentTenantId: null,
|
|
34
|
+
tenants: [],
|
|
35
|
+
permissions: [],
|
|
36
|
+
setTokens: vi.fn(),
|
|
37
|
+
setTenants: vi.fn(),
|
|
38
|
+
setPermissions: vi.fn(),
|
|
39
|
+
setCurrentTenant: vi.fn(),
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
getAccessToken: () => mockCtx.accessToken,
|
|
43
|
+
getRefreshToken: () => mockCtx.refreshToken,
|
|
44
|
+
getCurrentRole: () => null,
|
|
45
|
+
isTokenExpired: () => false,
|
|
46
|
+
loginWithTokens: vi.fn(),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
vi.mock('../roles', () => ({
|
|
50
|
+
buildLoginUrl: vi.fn(() => 'https://auth.example.com/login'),
|
|
51
|
+
isAuthLoginSurface: vi.fn(() => false),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock('../auth-trace', () => ({
|
|
55
|
+
traceClear: mockTraceClear,
|
|
56
|
+
traceEvent: mockTraceEvent,
|
|
57
|
+
traceRedirect: vi.fn(),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
vi.mock('../oauth-client-id-store', () => ({
|
|
61
|
+
resolveClientIdForSession: () => mockCtx.clientId,
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
vi.mock('../oauth-login', () => ({
|
|
65
|
+
discardPendingOAuthLogin: mockDiscard,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
vi.mock('../api/bff-client', () => ({
|
|
69
|
+
bffLogout: mockBffLogout,
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
vi.mock('../../config', () => ({
|
|
73
|
+
getApiBaseUrl: () => '/bff',
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
vi.mock('axios', () => ({
|
|
77
|
+
default: { post: mockPost },
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
import { AuthService } from '../service';
|
|
81
|
+
|
|
82
|
+
const JWT_RT = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1MSJ9.sig';
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
vi.clearAllMocks();
|
|
86
|
+
mockCtx.accessToken = null;
|
|
87
|
+
mockCtx.refreshToken = null;
|
|
88
|
+
mockCtx.clientId = '';
|
|
89
|
+
mockPost.mockResolvedValue({ data: {} });
|
|
90
|
+
localStorage.clear();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('AuthService.logout 分链吊销(AUTH-15)', () => {
|
|
94
|
+
it('OAuth 链(不透明 rt + client_id)→ oauth revoke ×2 带 client_id,不打 identity logout', async () => {
|
|
95
|
+
mockCtx.accessToken = 'at-opaque';
|
|
96
|
+
mockCtx.refreshToken = 'rt-opaque';
|
|
97
|
+
mockCtx.clientId = 'cid-1';
|
|
98
|
+
|
|
99
|
+
await AuthService.logout();
|
|
100
|
+
|
|
101
|
+
expect(mockPost).toHaveBeenCalledTimes(2);
|
|
102
|
+
const [url0, body0, cfg0] = mockPost.mock.calls[0];
|
|
103
|
+
expect(url0).toBe('/bff/oauth/api/v1/oauth/revoke');
|
|
104
|
+
const p0 = new URLSearchParams(body0 as string);
|
|
105
|
+
expect(p0.get('token')).toBe('at-opaque');
|
|
106
|
+
expect(p0.get('token_type_hint')).toBe('access_token');
|
|
107
|
+
expect(p0.get('client_id')).toBe('cid-1');
|
|
108
|
+
expect((cfg0 as any).headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
|
109
|
+
|
|
110
|
+
const [url1, body1] = mockPost.mock.calls[1];
|
|
111
|
+
expect(url1).toBe('/bff/oauth/api/v1/oauth/revoke');
|
|
112
|
+
const p1 = new URLSearchParams(body1 as string);
|
|
113
|
+
expect(p1.get('token')).toBe('rt-opaque');
|
|
114
|
+
expect(p1.get('token_type_hint')).toBe('refresh_token');
|
|
115
|
+
expect(p1.get('client_id')).toBe('cid-1');
|
|
116
|
+
|
|
117
|
+
expect(mockPost.mock.calls.some(([u]) => String(u).includes('identity'))).toBe(false);
|
|
118
|
+
expect(mockTraceEvent).toHaveBeenCalledWith('logout-revoke', {
|
|
119
|
+
reason: 'oauth-access_token',
|
|
120
|
+
to: 'ok',
|
|
121
|
+
});
|
|
122
|
+
expect(mockTraceEvent).toHaveBeenCalledWith('logout-revoke', {
|
|
123
|
+
reason: 'oauth-refresh_token',
|
|
124
|
+
to: 'ok',
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('OAuth 链无 AT(仅 rt)→ 只 revoke refresh_token', async () => {
|
|
129
|
+
mockCtx.refreshToken = 'rt-opaque';
|
|
130
|
+
mockCtx.clientId = 'cid-1';
|
|
131
|
+
|
|
132
|
+
await AuthService.logout();
|
|
133
|
+
|
|
134
|
+
expect(mockPost).toHaveBeenCalledTimes(1);
|
|
135
|
+
const p = new URLSearchParams(mockPost.mock.calls[0][1] as string);
|
|
136
|
+
expect(p.get('token')).toBe('rt-opaque');
|
|
137
|
+
expect(p.get('token_type_hint')).toBe('refresh_token');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('identity 链(JWT rt,无 client_id)→ identity /auth/logout 携 Bearer AT,不打 oauth revoke', async () => {
|
|
141
|
+
mockCtx.accessToken = 'at-jwt';
|
|
142
|
+
mockCtx.refreshToken = JWT_RT;
|
|
143
|
+
|
|
144
|
+
await AuthService.logout();
|
|
145
|
+
|
|
146
|
+
expect(mockPost).toHaveBeenCalledTimes(1);
|
|
147
|
+
const [url, body, cfg] = mockPost.mock.calls[0];
|
|
148
|
+
expect(url).toBe('/bff/identity/api/v1/auth/logout');
|
|
149
|
+
expect(body).toBeNull();
|
|
150
|
+
expect((cfg as any).headers.Authorization).toBe('Bearer at-jwt');
|
|
151
|
+
expect(mockTraceEvent).toHaveBeenCalledWith('logout-revoke', { reason: 'identity', to: 'ok' });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('JWT rt + 持久 client_id 仍走 identity(镜像 refreshToken 分链判据)', async () => {
|
|
155
|
+
mockCtx.accessToken = 'at-jwt';
|
|
156
|
+
mockCtx.refreshToken = JWT_RT;
|
|
157
|
+
mockCtx.clientId = 'cid-1';
|
|
158
|
+
|
|
159
|
+
await AuthService.logout();
|
|
160
|
+
|
|
161
|
+
expect(mockPost).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(mockPost.mock.calls[0][0]).toBe('/bff/identity/api/v1/auth/logout');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('仅剩无凭据 rt(identity JWT rt 且无 AT)→ 明示跳过并留痕', async () => {
|
|
166
|
+
mockCtx.refreshToken = JWT_RT;
|
|
167
|
+
|
|
168
|
+
await AuthService.logout();
|
|
169
|
+
|
|
170
|
+
expect(mockPost).not.toHaveBeenCalled();
|
|
171
|
+
expect(mockTraceEvent).toHaveBeenCalledWith('logout-revoke-skipped', {
|
|
172
|
+
reason: 'no-credential',
|
|
173
|
+
to: 'ok',
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('AUTH-08 登出卫生:清 localStorage 四键 + OAuth 预置物 + trace 缓冲 + 内存态', async () => {
|
|
178
|
+
mockCtx.accessToken = 'at-jwt';
|
|
179
|
+
mockCtx.refreshToken = JWT_RT;
|
|
180
|
+
localStorage.setItem('autional-auth-v1', 'x');
|
|
181
|
+
localStorage.setItem('access_token', 'x');
|
|
182
|
+
localStorage.setItem('refresh_token', 'x');
|
|
183
|
+
localStorage.setItem('__oauth_bridge_token', 'x');
|
|
184
|
+
|
|
185
|
+
await AuthService.logout();
|
|
186
|
+
|
|
187
|
+
expect(localStorage.getItem('autional-auth-v1')).toBeNull();
|
|
188
|
+
expect(localStorage.getItem('access_token')).toBeNull();
|
|
189
|
+
expect(localStorage.getItem('refresh_token')).toBeNull();
|
|
190
|
+
expect(localStorage.getItem('__oauth_bridge_token')).toBeNull();
|
|
191
|
+
expect(mockClearAuth).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(mockDiscard).toHaveBeenCalledTimes(1);
|
|
193
|
+
expect(mockTraceClear).toHaveBeenCalledTimes(1);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('吊销失败(4xx):trace 留痕 failed(带 HTTP status)且 logout 仍 resolve', async () => {
|
|
197
|
+
mockCtx.accessToken = 'at-jwt';
|
|
198
|
+
mockCtx.refreshToken = JWT_RT;
|
|
199
|
+
mockPost.mockRejectedValueOnce({ response: { status: 401 } });
|
|
200
|
+
|
|
201
|
+
await expect(AuthService.logout()).resolves.toBeUndefined();
|
|
202
|
+
expect(mockTraceEvent).toHaveBeenCalledWith('logout-revoke-failed', {
|
|
203
|
+
reason: 'identity',
|
|
204
|
+
to: '401',
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('吊销失败(网络异常,无 response):to=network-error 且 logout 仍 resolve', async () => {
|
|
209
|
+
mockCtx.accessToken = 'at-jwt';
|
|
210
|
+
mockCtx.refreshToken = JWT_RT;
|
|
211
|
+
mockPost.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
|
212
|
+
|
|
213
|
+
await expect(AuthService.logout()).resolves.toBeUndefined();
|
|
214
|
+
expect(mockTraceEvent).toHaveBeenCalledWith('logout-revoke-failed', {
|
|
215
|
+
reason: 'identity',
|
|
216
|
+
to: 'network-error',
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// AUTH-46② 回归锁:handleOAuthCallback 失败路径以 OAuthCallbackError 携带稳定 code
|
|
3
|
+
// (oauth.provider_error / oauth.missing_code / oauth.state_mismatch / oauth.pkce_cleared),
|
|
4
|
+
// 供回调页按 code 映射本地化文案——此前英文裸句("Missing authorization code" 等)直接落用户屏。
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
7
|
+
|
|
8
|
+
const { mockPost } = vi.hoisted(() => ({ mockPost: vi.fn() }));
|
|
9
|
+
|
|
10
|
+
vi.mock('../../api/client', () => ({ apiClient: { post: mockPost, get: vi.fn() } }));
|
|
11
|
+
vi.mock('../store', () => ({ loginWithTokens: vi.fn() }));
|
|
12
|
+
vi.mock('../service', () => ({ AuthService: { updateCurrentTenant: vi.fn() } }));
|
|
13
|
+
vi.mock('../oauth-client-id-store', () => ({ persistOAuthClientId: vi.fn() }));
|
|
14
|
+
vi.mock('../auth-trace', () => ({ traceRedirect: vi.fn() }));
|
|
15
|
+
vi.mock('../../config', () => ({ getPortalUrl: () => 'https://auth.example.com' }));
|
|
16
|
+
|
|
17
|
+
import { handleOAuthCallback, OAuthCallbackError } from '../oauth-login';
|
|
18
|
+
|
|
19
|
+
async function codeOf(promise: Promise<unknown>): Promise<string> {
|
|
20
|
+
try {
|
|
21
|
+
await promise;
|
|
22
|
+
throw new Error('expected rejection');
|
|
23
|
+
} catch (e) {
|
|
24
|
+
expect(e).toBeInstanceOf(OAuthCallbackError);
|
|
25
|
+
return (e as OAuthCallbackError).code;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
sessionStorage.clear();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('handleOAuthCallback 错误 code(AUTH-46②)', () => {
|
|
35
|
+
it('provider error 参数 → oauth.provider_error(description 作 message 保留)', async () => {
|
|
36
|
+
window.history.replaceState({}, '', '/?error=access_denied&error_description=user+denied');
|
|
37
|
+
const code = await codeOf(handleOAuthCallback());
|
|
38
|
+
expect(code).toBe('oauth.provider_error');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('无 code → oauth.missing_code', async () => {
|
|
42
|
+
window.history.replaceState({}, '', '/?state=%7B%22csrf%22%3A%22a%22%7D');
|
|
43
|
+
const code = await codeOf(handleOAuthCallback());
|
|
44
|
+
expect(code).toBe('oauth.missing_code');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('state csrf 不匹配 → oauth.state_mismatch', async () => {
|
|
48
|
+
sessionStorage.setItem('oauth_state', 'csrf-A');
|
|
49
|
+
sessionStorage.setItem('oauth_pkce_verifier', 'v-1');
|
|
50
|
+
window.history.replaceState(
|
|
51
|
+
{},
|
|
52
|
+
'',
|
|
53
|
+
'/?code=c1&state=' + encodeURIComponent(JSON.stringify({ csrf: 'csrf-B' })),
|
|
54
|
+
);
|
|
55
|
+
const code = await codeOf(handleOAuthCallback());
|
|
56
|
+
expect(code).toBe('oauth.state_mismatch');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('state 通过但 PKCE verifier 缺失(刷新清存储)→ oauth.pkce_cleared', async () => {
|
|
60
|
+
sessionStorage.setItem('oauth_state', 'csrf-A');
|
|
61
|
+
window.history.replaceState(
|
|
62
|
+
{},
|
|
63
|
+
'',
|
|
64
|
+
'/?code=c1&state=' + encodeURIComponent(JSON.stringify({ csrf: 'csrf-A' })),
|
|
65
|
+
);
|
|
66
|
+
const code = await codeOf(handleOAuthCallback());
|
|
67
|
+
expect(code).toBe('oauth.pkce_cleared');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import '@testing-library/jest-dom/vitest';
|
|
3
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
4
|
+
import { renderHook, cleanup, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
// ============================================================
|
|
7
|
+
// F-W6 判别依据:按 slug 拉取公开 auth-config 的三态
|
|
8
|
+
// - ok(2xx)
|
|
9
|
+
// - unknown-slug(**HTTP 404**,租户确定性不存在)→ 门户侧闸门判死
|
|
10
|
+
// - error(网络/5xx/解析失败)→ 必须 fail-open(not-found,仍走登录漏斗)
|
|
11
|
+
// 契约来源:identity public_handler.go GetTenantAuthConfigBySlug ——
|
|
12
|
+
// 租户不存在 → 404 ErrCodeNotFound;已知租户未配 client → 200(client 空)。
|
|
13
|
+
// ============================================================
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
useOAuthClientIdFromUrl,
|
|
17
|
+
fetchOAuthClientIdBySlug,
|
|
18
|
+
} from '../oauth-client-from-slug';
|
|
19
|
+
|
|
20
|
+
function setPath(path: string) {
|
|
21
|
+
window.history.pushState({}, '', path);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stubFetch(ok: boolean, status: number, body?: unknown) {
|
|
25
|
+
vi.stubGlobal(
|
|
26
|
+
'fetch',
|
|
27
|
+
vi.fn().mockResolvedValue({
|
|
28
|
+
ok,
|
|
29
|
+
status,
|
|
30
|
+
json: async () => body,
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
setPath('/acme');
|
|
37
|
+
delete (window as any).__APP_CONFIG__;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
cleanup();
|
|
42
|
+
vi.unstubAllGlobals();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('useOAuthClientIdFromUrl(三态判别)', () => {
|
|
46
|
+
it('HTTP 404 → unknown-slug(确定性不存在,带 slug)', async () => {
|
|
47
|
+
stubFetch(false, 404);
|
|
48
|
+
const { result } = renderHook(() => useOAuthClientIdFromUrl());
|
|
49
|
+
|
|
50
|
+
await waitFor(() => expect(result.current.status).toBe('unknown-slug'));
|
|
51
|
+
expect(result.current).toMatchObject({ status: 'unknown-slug', slug: 'acme' });
|
|
52
|
+
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('by-slug/acme'));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('HTTP 500 → not-found(fail-open,不得判死;回归锁)', async () => {
|
|
56
|
+
stubFetch(false, 500);
|
|
57
|
+
const { result } = renderHook(() => useOAuthClientIdFromUrl());
|
|
58
|
+
|
|
59
|
+
await waitFor(() => expect(result.current.status).toBe('not-found'));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('网络异常 → not-found(fail-open)', async () => {
|
|
63
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
|
|
64
|
+
const { result } = renderHook(() => useOAuthClientIdFromUrl());
|
|
65
|
+
|
|
66
|
+
await waitFor(() => expect(result.current.status).toBe('not-found'));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('200 + data.oauthClientId(camel)→ resolved', async () => {
|
|
70
|
+
stubFetch(true, 200, { data: { oauthClientId: 'cid-1', tenantId: 't1' } });
|
|
71
|
+
const { result } = renderHook(() => useOAuthClientIdFromUrl());
|
|
72
|
+
|
|
73
|
+
await waitFor(() => expect(result.current.status).toBe('resolved'));
|
|
74
|
+
expect(result.current).toMatchObject({
|
|
75
|
+
status: 'resolved',
|
|
76
|
+
slug: 'acme',
|
|
77
|
+
clientId: 'cid-1',
|
|
78
|
+
config: { tenantId: 't1' },
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('200 + 扁平 oauth_client_id(snake)→ resolved', async () => {
|
|
83
|
+
stubFetch(true, 200, { oauth_client_id: 'cid-snake' });
|
|
84
|
+
const { result } = renderHook(() => useOAuthClientIdFromUrl());
|
|
85
|
+
|
|
86
|
+
await waitFor(() => expect(result.current.status).toBe('resolved'));
|
|
87
|
+
expect(result.current).toMatchObject({ status: 'resolved', clientId: 'cid-snake' });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('200 但无 client(已知租户未配)→ not-found(不误判 unknown-slug)', async () => {
|
|
91
|
+
stubFetch(true, 200, { data: { tenantId: 't1' } });
|
|
92
|
+
const { result } = renderHook(() => useOAuthClientIdFromUrl());
|
|
93
|
+
|
|
94
|
+
await waitFor(() => expect(result.current.status).toBe('not-found'));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('根路径无 slug → not-found 且不发请求', async () => {
|
|
98
|
+
setPath('/');
|
|
99
|
+
stubFetch(true, 200, { data: { oauthClientId: 'cid-1' } });
|
|
100
|
+
const { result } = renderHook(() => useOAuthClientIdFromUrl());
|
|
101
|
+
|
|
102
|
+
await waitFor(() => expect(result.current.status).toBe('not-found'));
|
|
103
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('env 固定 client(admin-console 旁路)→ not-found 且不发请求', async () => {
|
|
107
|
+
setPath('/users');
|
|
108
|
+
(window as any).__APP_CONFIG__ = { VITE_OAUTH_CLIENT_ID: 'cid-env' };
|
|
109
|
+
stubFetch(true, 200, { data: { oauthClientId: 'cid-1' } });
|
|
110
|
+
const { result } = renderHook(() => useOAuthClientIdFromUrl());
|
|
111
|
+
|
|
112
|
+
await waitFor(() => expect(result.current.status).toBe('not-found'));
|
|
113
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('fetchOAuthClientIdBySlug(非 hook 版本,登录页回源)', () => {
|
|
118
|
+
it('200 + client → 返回 clientId', async () => {
|
|
119
|
+
stubFetch(true, 200, { data: { oauthClientId: 'cid-1' } });
|
|
120
|
+
await expect(fetchOAuthClientIdBySlug('acme')).resolves.toBe('cid-1');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('404 → null', async () => {
|
|
124
|
+
stubFetch(false, 404);
|
|
125
|
+
await expect(fetchOAuthClientIdBySlug('acme')).resolves.toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('网络异常 → null', async () => {
|
|
129
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
|
|
130
|
+
await expect(fetchOAuthClientIdBySlug('acme')).resolves.toBeNull();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// ============================================================
|
|
5
|
+
// AUTH-03/08:initiateOAuthLogin 的「停留」语义与防重入窗口
|
|
6
|
+
// - onStay 转发给调用方(RequireAuth 据此渲染恢复态)并清零本次预置物
|
|
7
|
+
// - 「停留」取消待重试定时器:窗口过期后不再自动把用户弹走
|
|
8
|
+
// - force=true 跳过 10s 防重入窗口(恢复态「前往登录」按钮用)
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
const { mockTrace, mockPersistCid, mockGeneratePKCE } = vi.hoisted(() => ({
|
|
12
|
+
mockTrace: vi.fn(),
|
|
13
|
+
mockPersistCid: vi.fn(),
|
|
14
|
+
mockGeneratePKCE: vi.fn(() => Promise.resolve({ verifier: 'v-1', challenge: 'c-1' })),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock('../../api/client', () => ({ apiClient: { post: vi.fn(), get: vi.fn() } }));
|
|
18
|
+
vi.mock('../store', () => ({ loginWithTokens: vi.fn() }));
|
|
19
|
+
vi.mock('../service', () => ({ AuthService: { updateCurrentTenant: vi.fn() } }));
|
|
20
|
+
vi.mock('../oauth-client-id-store', () => ({ persistOAuthClientId: mockPersistCid }));
|
|
21
|
+
vi.mock('../auth-trace', () => ({ traceRedirect: mockTrace }));
|
|
22
|
+
vi.mock('../pkce', () => ({ generatePKCE: () => mockGeneratePKCE() }));
|
|
23
|
+
vi.mock('../../config', () => ({ getPortalUrl: () => 'https://auth.example.com' }));
|
|
24
|
+
|
|
25
|
+
import { initiateOAuthLogin, discardPendingOAuthLogin } from '../oauth-login';
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
sessionStorage.clear();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.useRealTimers();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('initiateOAuthLogin「停留」与防重入(AUTH-03/08)', () => {
|
|
37
|
+
it('「停留」→ onStay 转发 + 本次预置物全清(state/verifier/cid/init_ts)', async () => {
|
|
38
|
+
const onStay = vi.fn();
|
|
39
|
+
await initiateOAuthLogin('cid-1', 'https://user.example.com/x', { onStay });
|
|
40
|
+
|
|
41
|
+
expect(mockTrace).toHaveBeenCalledTimes(1);
|
|
42
|
+
const opts = mockTrace.mock.calls[0][1];
|
|
43
|
+
expect(opts.kind).toBe('interstitial');
|
|
44
|
+
expect(typeof opts.onStay).toBe('function');
|
|
45
|
+
// 发起时预置物就位
|
|
46
|
+
expect(sessionStorage.getItem('oauth_state')).toBeTruthy();
|
|
47
|
+
expect(sessionStorage.getItem('oauth_pkce_verifier')).toBe('v-1');
|
|
48
|
+
expect(sessionStorage.getItem('oauth_client_id')).toBe('cid-1');
|
|
49
|
+
expect(sessionStorage.getItem('oauth_init_ts')).toBeTruthy();
|
|
50
|
+
|
|
51
|
+
opts.onStay();
|
|
52
|
+
|
|
53
|
+
expect(onStay).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(sessionStorage.getItem('oauth_state')).toBeNull();
|
|
55
|
+
expect(sessionStorage.getItem('oauth_pkce_verifier')).toBeNull();
|
|
56
|
+
expect(sessionStorage.getItem('oauth_client_id')).toBeNull();
|
|
57
|
+
expect(sessionStorage.getItem('oauth_init_ts')).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('10s 防重入窗口内默认被抑制(无跳转);force=true 直通', async () => {
|
|
61
|
+
sessionStorage.setItem('oauth_init_ts', String(Date.now()));
|
|
62
|
+
|
|
63
|
+
await initiateOAuthLogin('cid-1');
|
|
64
|
+
expect(mockTrace).not.toHaveBeenCalled();
|
|
65
|
+
|
|
66
|
+
await initiateOAuthLogin('cid-1', undefined, { force: true });
|
|
67
|
+
expect(mockTrace).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('「停留」取消待重试定时器:窗口过期后不再自动发起', async () => {
|
|
71
|
+
vi.useFakeTimers();
|
|
72
|
+
sessionStorage.setItem('oauth_init_ts', String(Date.now()));
|
|
73
|
+
|
|
74
|
+
// 窗口内首次调用被抑制,安排窗口过期后的自动重试
|
|
75
|
+
await initiateOAuthLogin('cid-1');
|
|
76
|
+
expect(mockTrace).not.toHaveBeenCalled();
|
|
77
|
+
|
|
78
|
+
// 窗口过期 → 自动重试真正发起,拿到本轮的 onStay
|
|
79
|
+
await vi.advanceTimersByTimeAsync(10200);
|
|
80
|
+
expect(mockTrace).toHaveBeenCalledTimes(1);
|
|
81
|
+
const opts = mockTrace.mock.calls[0][1];
|
|
82
|
+
opts.onStay();
|
|
83
|
+
|
|
84
|
+
// 预置物清零 + 定时器已取消:再次调用立即发起(不被窗口吞、无遗留重试)
|
|
85
|
+
mockTrace.mockClear();
|
|
86
|
+
await initiateOAuthLogin('cid-1');
|
|
87
|
+
expect(mockTrace).toHaveBeenCalledTimes(1);
|
|
88
|
+
await vi.advanceTimersByTimeAsync(15000);
|
|
89
|
+
expect(mockTrace).toHaveBeenCalledTimes(1);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('discardPendingOAuthLogin(AUTH-08 会话卫生,登出/停留共用)', () => {
|
|
94
|
+
it('清 sessionStorage 四键 + 取消待重试定时器', async () => {
|
|
95
|
+
vi.useFakeTimers();
|
|
96
|
+
sessionStorage.setItem('oauth_init_ts', String(Date.now()));
|
|
97
|
+
|
|
98
|
+
// 窗口内首次调用被抑制 → 安排窗口过期后的自动重试
|
|
99
|
+
await initiateOAuthLogin('cid-1');
|
|
100
|
+
expect(mockTrace).not.toHaveBeenCalled();
|
|
101
|
+
|
|
102
|
+
// 模拟发起中途残留的预置物
|
|
103
|
+
sessionStorage.setItem('oauth_state', 's-1');
|
|
104
|
+
sessionStorage.setItem('oauth_pkce_verifier', 'v-1');
|
|
105
|
+
sessionStorage.setItem('oauth_client_id', 'cid-1');
|
|
106
|
+
|
|
107
|
+
discardPendingOAuthLogin();
|
|
108
|
+
|
|
109
|
+
expect(sessionStorage.getItem('oauth_state')).toBeNull();
|
|
110
|
+
expect(sessionStorage.getItem('oauth_pkce_verifier')).toBeNull();
|
|
111
|
+
expect(sessionStorage.getItem('oauth_client_id')).toBeNull();
|
|
112
|
+
expect(sessionStorage.getItem('oauth_init_ts')).toBeNull();
|
|
113
|
+
|
|
114
|
+
// 重试定时器已取消:窗口过期后不再自动发起
|
|
115
|
+
await vi.advanceTimersByTimeAsync(15000);
|
|
116
|
+
expect(mockTrace).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
});
|