@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
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@autional/shared",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0-rc.32",
|
|
4
|
+
"description": "Autional shared layer — API client, auth, types, hooks, utils",
|
|
5
|
+
"license": "AGPL-3.0",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"module": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./branding": "./src/branding/index.ts",
|
|
12
|
+
"./slug-from-url": "./src/auth/slug-from-url.ts",
|
|
13
|
+
"./generated/api": "./src/generated/api.ts",
|
|
14
|
+
"./generated/types": "./src/generated/types.ts",
|
|
15
|
+
"./generated/queries": "./src/generated/queries.ts",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@opentelemetry/api": "1.9.1",
|
|
24
|
+
"@tanstack/react-query": "5.100.9",
|
|
25
|
+
"@types/lodash-es": "4.17.12",
|
|
26
|
+
"@types/node": "26.1.2",
|
|
27
|
+
"@types/react": "19.2.14",
|
|
28
|
+
"@types/react-dom": "19.2.3",
|
|
29
|
+
"axios": "1.16.0",
|
|
30
|
+
"lodash-es": "4.18.1",
|
|
31
|
+
"zustand": "5.0.13"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@testing-library/jest-dom": "6.9.1",
|
|
35
|
+
"@testing-library/react": "16.3.2",
|
|
36
|
+
"jsdom": "29.1.1",
|
|
37
|
+
"react": "19.2.5",
|
|
38
|
+
"react-dom": "19.2.5",
|
|
39
|
+
"typescript": "5.8.3",
|
|
40
|
+
"vite": "6.4.2",
|
|
41
|
+
"vitest": "3.2.0",
|
|
42
|
+
"@autional/tsconfig": "0.1.0-rc"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"react": ">=18.0.0",
|
|
46
|
+
"react-dom": ">=18.0.0"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"test": "vitest run"
|
|
51
|
+
}
|
|
6
52
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// TASK-AB1-28(fix-admin-b1-guard-contract / AC-AB1-51):apiClient 拦截器契约 T1 回归锁。
|
|
2
|
+
//
|
|
3
|
+
// 断言口径 —— **全部 7 组均走真实拦截器**(只替换 axios adapter:请求拦截器之后最末环;
|
|
4
|
+
// 零 interceptor mock、零 spy 替换 setupInterceptors —— 供审查方逐组核对)。
|
|
5
|
+
// 锁定 client.ts 实际行为:
|
|
6
|
+
// 请求侧 :51-58(config.data / config.params → snakeCaseKeys);响应侧 :81-105(信封装配 + camelCaseKeys)。
|
|
7
|
+
// 组清单:
|
|
8
|
+
// G1 params:pageSize/currentPage → page_size/current_page(:56-58)
|
|
9
|
+
// G2 请求体:camel 嵌套对象/数组 → snake 深转换(:51-53)
|
|
10
|
+
// G3 透传:limit/keyword 未声明参数不被误信改写(snake 转换仅大写字母加 _,无语义改写)
|
|
11
|
+
// G4 响应深 camel:snake → camel 递归(:105)
|
|
12
|
+
// G5 信封:{code,message,data} → data 直给(:85-87;信封键不残留)
|
|
13
|
+
// G6 items 分支:{code,items,total,pagination} → {items,total,pagination}(:89-95)
|
|
14
|
+
// G7 简单分支:{code,message}(无 data/items)→ 整体返回(:97-99)+ raw_key → rawKey 代表样本
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
17
|
+
import { apiClient } from '../client';
|
|
18
|
+
|
|
19
|
+
interface CapturedCall {
|
|
20
|
+
method: string;
|
|
21
|
+
url: string;
|
|
22
|
+
params?: Record<string, unknown>;
|
|
23
|
+
body?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let captured: CapturedCall[];
|
|
27
|
+
let originalAdapter: unknown;
|
|
28
|
+
let respond: (config: unknown) => unknown;
|
|
29
|
+
|
|
30
|
+
function installCaptureAdapter() {
|
|
31
|
+
originalAdapter = apiClient.defaults.adapter;
|
|
32
|
+
apiClient.defaults.adapter = (async (config: any) => {
|
|
33
|
+
let body: Record<string, unknown> | undefined;
|
|
34
|
+
if (typeof config.data === 'string' && config.data.length > 0) {
|
|
35
|
+
body = JSON.parse(config.data);
|
|
36
|
+
} else if (config.data && typeof config.data === 'object') {
|
|
37
|
+
body = config.data as Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
captured.push({
|
|
40
|
+
method: String(config.method || 'get').toLowerCase(),
|
|
41
|
+
url: String(config.url || ''),
|
|
42
|
+
params: config.params,
|
|
43
|
+
body,
|
|
44
|
+
});
|
|
45
|
+
return { data: respond(config), status: 200, statusText: 'OK', headers: {}, config };
|
|
46
|
+
}) as any;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('apiClient 拦截器契约(AC-AB1-51 · T1 护栏)', () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
captured = [];
|
|
52
|
+
respond = () => ({ code: 0, message: 'success', timestamp: '2026-10-04T00:00:00Z' });
|
|
53
|
+
installCaptureAdapter();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
apiClient.defaults.adapter = originalAdapter as any;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('G1 请求 params:camel → snake(pageSize → page_size),走真实拦截器', async () => {
|
|
61
|
+
await apiClient.get('/contract/probe', { params: { pageSize: 20, currentPage: 2 } });
|
|
62
|
+
expect(captured).toHaveLength(1);
|
|
63
|
+
expect(captured[0].params).toEqual({ page_size: 20, current_page: 2 });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('G2 请求体:camel 嵌套对象/数组 → snake 深转换,走真实拦截器', async () => {
|
|
67
|
+
await apiClient.post('/contract/probe', {
|
|
68
|
+
userProfile: { displayName: '张三', nestedArray: [{ itemId: 1, subLevel: { deepKey: true } }] },
|
|
69
|
+
totalCount: 3,
|
|
70
|
+
});
|
|
71
|
+
expect(captured).toHaveLength(1);
|
|
72
|
+
expect(captured[0].body).toEqual({
|
|
73
|
+
user_profile: {
|
|
74
|
+
display_name: '张三',
|
|
75
|
+
nested_array: [{ item_id: 1, sub_level: { deep_key: true } }],
|
|
76
|
+
},
|
|
77
|
+
total_count: 3,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('G3 透传:limit/keyword 未声明参数不被误信改写(无语义转换),走真实拦截器', async () => {
|
|
82
|
+
await apiClient.get('/contract/probe', { params: { limit: 3, keyword: 'ali', pageSize: 10 } });
|
|
83
|
+
expect(captured[0].params).toEqual({ limit: 3, keyword: 'ali', page_size: 10 });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('G4 响应:snake → camel 深递归(嵌套对象/数组),走真实拦截器', async () => {
|
|
87
|
+
respond = () => ({
|
|
88
|
+
code: 0,
|
|
89
|
+
message: 'success',
|
|
90
|
+
data: {
|
|
91
|
+
user_profile: {
|
|
92
|
+
display_name: '张三',
|
|
93
|
+
company_name: 'ACME',
|
|
94
|
+
nested_list: [{ item_id: 1, created_at: '2026-10-04T00:00:00Z' }],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
timestamp: '2026-10-04T00:00:00Z',
|
|
98
|
+
});
|
|
99
|
+
const res = await apiClient.get('/contract/probe');
|
|
100
|
+
expect(res.data).toEqual({
|
|
101
|
+
userProfile: {
|
|
102
|
+
displayName: '张三',
|
|
103
|
+
companyName: 'ACME',
|
|
104
|
+
nestedList: [{ itemId: 1, createdAt: '2026-10-04T00:00:00Z' }],
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('G5 响应:{code,message,data} 信封解包(data 直给,信封键不残留),走真实拦截器', async () => {
|
|
110
|
+
respond = () => ({
|
|
111
|
+
code: 0,
|
|
112
|
+
message: 'success',
|
|
113
|
+
data: { raw_key: 'v' },
|
|
114
|
+
timestamp: '2026-10-04T00:00:00Z',
|
|
115
|
+
});
|
|
116
|
+
const res = await apiClient.get('/contract/probe');
|
|
117
|
+
expect(res.data).toEqual({ rawKey: 'v' });
|
|
118
|
+
expect((res.data as Record<string, unknown>).code).toBeUndefined();
|
|
119
|
+
expect((res.data as Record<string, unknown>).message).toBeUndefined();
|
|
120
|
+
expect((res.data as Record<string, unknown>).timestamp).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('G6 响应:items 分支 {code,items,total,pagination} → 归一三键(条目深 camel),走真实拦截器', async () => {
|
|
124
|
+
respond = () => ({
|
|
125
|
+
code: 0,
|
|
126
|
+
message: 'success',
|
|
127
|
+
items: [{ raw_key: 'a', user_name: 'n' }],
|
|
128
|
+
total: 1,
|
|
129
|
+
pagination: { page: 1, page_size: 10, total_pages: 1 },
|
|
130
|
+
timestamp: '2026-10-04T00:00:00Z',
|
|
131
|
+
});
|
|
132
|
+
const res = await apiClient.get('/contract/probe');
|
|
133
|
+
expect(res.data).toEqual({
|
|
134
|
+
items: [{ rawKey: 'a', userName: 'n' }],
|
|
135
|
+
total: 1,
|
|
136
|
+
pagination: { page: 1, pageSize: 10, totalPages: 1 },
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('G7 响应:简单分支(无 data/items)整体返回 + raw_key → rawKey 代表样本,走真实拦截器', async () => {
|
|
141
|
+
respond = () => ({
|
|
142
|
+
code: 0,
|
|
143
|
+
message: 'success',
|
|
144
|
+
raw_key: 'v',
|
|
145
|
+
timestamp: '2026-10-04T00:00:00Z',
|
|
146
|
+
});
|
|
147
|
+
const res = await apiClient.get('/contract/probe');
|
|
148
|
+
expect(res.data).toEqual({
|
|
149
|
+
code: 0,
|
|
150
|
+
message: 'success',
|
|
151
|
+
rawKey: 'v',
|
|
152
|
+
timestamp: '2026-10-04T00:00:00Z',
|
|
153
|
+
});
|
|
154
|
+
expect((res.data as Record<string, unknown>).raw_key).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('G8 请求体:缩写键 acronym-aware(U385:clientDataJSON → client_data_json,走真实拦截器)', async () => {
|
|
158
|
+
await apiClient.post('/contract/probe', {
|
|
159
|
+
clientDataJSON: 'cdj',
|
|
160
|
+
attestationObject: 'ao',
|
|
161
|
+
rawId: 'rid',
|
|
162
|
+
userID: 'u-1',
|
|
163
|
+
});
|
|
164
|
+
expect(captured).toHaveLength(1);
|
|
165
|
+
expect(captured[0].body).toEqual({
|
|
166
|
+
client_data_json: 'cdj',
|
|
167
|
+
attestation_object: 'ao',
|
|
168
|
+
raw_id: 'rid',
|
|
169
|
+
user_id: 'u-1',
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { resolveEntryPlane } from '../entry-plane';
|
|
3
|
+
|
|
4
|
+
describe('resolveEntryPlane', () => {
|
|
5
|
+
it('redirect 指向 admin 门户主机 → admin', () => {
|
|
6
|
+
expect(resolveEntryPlane('https://admin.iam.tianv.com/dashboard', 'auth.iam.tianv.com')).toBe(
|
|
7
|
+
'admin',
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('redirect 指向 platform 门户主机 → platform', () => {
|
|
12
|
+
expect(resolveEntryPlane('https://platform.iam.tianv.com/tenants', 'auth.iam.tianv.com')).toBe(
|
|
13
|
+
'platform',
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('redirect 指向非管理门户(user)→ 回落当前主机(auth)→ api', () => {
|
|
18
|
+
expect(resolveEntryPlane('https://user.iam.tianv.com/dashboard', 'auth.iam.tianv.com')).toBe(
|
|
19
|
+
'api',
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('redirect 指向安全仪表盘主机 → admin(SOC 门户为 admin 受众)', () => {
|
|
24
|
+
expect(
|
|
25
|
+
resolveEntryPlane('https://security.iam.tianv.com/risk-dashboard', 'auth.iam.tianv.com'),
|
|
26
|
+
).toBe('admin');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('无 redirect、当前主机为安全仪表盘 → admin', () => {
|
|
30
|
+
expect(resolveEntryPlane(null, 'security.iam.tianv.com')).toBe('admin');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('无 redirect、当前主机为 admin 门户 → admin', () => {
|
|
34
|
+
expect(resolveEntryPlane(null, 'admin.iam.tianv.com')).toBe('admin');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('无 redirect、当前主机为 auth 门户 → api', () => {
|
|
38
|
+
expect(resolveEntryPlane(null, 'auth.iam.tianv.com')).toBe('api');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('redirect 为相对路径时按当前主机判定', () => {
|
|
42
|
+
expect(resolveEntryPlane('/admin/dashboard', 'platform.iam.tianv.com')).toBe('platform');
|
|
43
|
+
expect(resolveEntryPlane('/dashboard', 'auth.iam.tianv.com')).toBe('api');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('redirect 非法时回落当前主机判定', () => {
|
|
47
|
+
expect(resolveEntryPlane('http://[invalid', 'auth.iam.tianv.com')).toBe('api');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('主机前缀大小写不敏感、带端口仍匹配', () => {
|
|
51
|
+
expect(resolveEntryPlane('https://ADMIN.iam.tianv.com/', 'auth.iam.tianv.com')).toBe('admin');
|
|
52
|
+
expect(resolveEntryPlane('https://admin.iam.tianv.com:8443/', 'auth.iam.tianv.com')).toBe(
|
|
53
|
+
'admin',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// 冷启动链:同意页无会话 → `<slug>/login?redirect=<authorize URL>`(oauth-cold-start TASK-07)。
|
|
59
|
+
// authorize URL 宿主是 auth 门户 ⇒ 宿主判定为空,必须下钻内嵌目标(redirect_uri / state.redirect),
|
|
60
|
+
// 否则回程门户拿到的 token 平面恒为 api,admin/platform 受众端点全部 403 40000503(F-W5)。
|
|
61
|
+
describe('resolveEntryPlane(authorize URL 作为登录页 redirect)', () => {
|
|
62
|
+
/** 复刻 initiateOAuthLogin 的 authorize URL 组装(oauth-login.ts)。 */
|
|
63
|
+
function authorizeUrl(params: Record<string, string>): string {
|
|
64
|
+
const qs = new URLSearchParams({ response_type: 'code', client_id: 'cid-1', ...params });
|
|
65
|
+
return `https://auth.iam.tianv.com/oauth/api/v1/oauth/authorize?${qs}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
it('redirect_uri 指向 admin 门户回调 → admin', () => {
|
|
69
|
+
const url = authorizeUrl({
|
|
70
|
+
redirect_uri: 'https://admin.iam.tianv.com/oauth/callback',
|
|
71
|
+
state: JSON.stringify({ csrf: 'x', redirect: 'https://admin.iam.tianv.com/demo/dashboard' }),
|
|
72
|
+
});
|
|
73
|
+
expect(resolveEntryPlane(url, 'auth.iam.tianv.com')).toBe('admin');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('redirect_uri 缺失时以 state.redirect(发起页 URL)判定 → platform', () => {
|
|
77
|
+
const url = authorizeUrl({
|
|
78
|
+
state: JSON.stringify({ csrf: 'x', redirect: 'https://platform.iam.tianv.com/tenants' }),
|
|
79
|
+
});
|
|
80
|
+
expect(resolveEntryPlane(url, 'auth.iam.tianv.com')).toBe('platform');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('内嵌目标为安全仪表盘 → admin(SOC 门户与 admin 同平面)', () => {
|
|
84
|
+
const url = authorizeUrl({ redirect_uri: 'https://security.iam.tianv.com/oauth/callback' });
|
|
85
|
+
expect(resolveEntryPlane(url, 'auth.iam.tianv.com')).toBe('admin');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('内嵌目标为 user 门户 / 第三方应用 → 回落 api', () => {
|
|
89
|
+
expect(
|
|
90
|
+
resolveEntryPlane(
|
|
91
|
+
authorizeUrl({ redirect_uri: 'https://user.iam.tianv.com/oauth/callback' }),
|
|
92
|
+
'auth.iam.tianv.com',
|
|
93
|
+
),
|
|
94
|
+
).toBe('api');
|
|
95
|
+
expect(
|
|
96
|
+
resolveEntryPlane(
|
|
97
|
+
authorizeUrl({ redirect_uri: 'https://app.example.com/oauth/callback' }),
|
|
98
|
+
'auth.iam.tianv.com',
|
|
99
|
+
),
|
|
100
|
+
).toBe('api');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('state 非 JSON(旧式纯串)不抛错,按 redirect_uri 判定', () => {
|
|
104
|
+
const url = authorizeUrl({
|
|
105
|
+
redirect_uri: 'https://admin.iam.tianv.com/oauth/callback',
|
|
106
|
+
state: 'plain-state',
|
|
107
|
+
});
|
|
108
|
+
expect(resolveEntryPlane(url, 'auth.iam.tianv.com')).toBe('admin');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('非 authorize 路径的 redirect_uri 参数不参与判定(防普通深链伪造平面)', () => {
|
|
112
|
+
expect(
|
|
113
|
+
resolveEntryPlane(
|
|
114
|
+
'https://user.iam.tianv.com/demo/?redirect_uri=https://admin.iam.tianv.com/',
|
|
115
|
+
'auth.iam.tianv.com',
|
|
116
|
+
),
|
|
117
|
+
).toBe('api');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { needsNamespaceHeader } from '../namespace';
|
|
3
|
+
|
|
4
|
+
describe('needsNamespaceHeader', () => {
|
|
5
|
+
it('密钥 admin 组端点(列表/子路径/查询)需注 X-Namespace', () => {
|
|
6
|
+
expect(needsNamespaceHeader('/secret/api/v1/admin/secrets')).toBe(true);
|
|
7
|
+
expect(needsNamespaceHeader('/secret/api/v1/admin/secrets/detail?key=a/b')).toBe(true);
|
|
8
|
+
expect(needsNamespaceHeader('/secret/api/v1/admin/secrets/batch-delete')).toBe(true);
|
|
9
|
+
expect(needsNamespaceHeader('/secret/api/v1/admin/secrets/policy')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('非密钥 admin 端点不注头', () => {
|
|
13
|
+
expect(needsNamespaceHeader('/notification/api/v1/admin/announcements')).toBe(false);
|
|
14
|
+
expect(needsNamespaceHeader('/tenant/api/v1/admin/tenants')).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('secret 公开面/internal 面不注头(前端不消费)', () => {
|
|
18
|
+
expect(needsNamespaceHeader('/secret/public/jwt/public-key')).toBe(false);
|
|
19
|
+
expect(needsNamespaceHeader('/secret/api/v1/secret/public/transmission/public-key')).toBe(false);
|
|
20
|
+
expect(needsNamespaceHeader('/api/v1/internal/secret/jwt/keys')).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('相对路径缺省(undefined/空)不注头', () => {
|
|
24
|
+
expect(needsNamespaceHeader(undefined)).toBe(false);
|
|
25
|
+
expect(needsNamespaceHeader('')).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
// U352 回归锁:预判式刷新(token 已过期 → refreshToken)成功后,必须继续走完请求注入链
|
|
3
|
+
// (Authorization=新 token + X-Tenant-ID + X-Namespace + params snakeCase),
|
|
4
|
+
// 不得提前 return —— 旧实现在此行 `return config` 跳过后方全部注入。
|
|
5
|
+
//
|
|
6
|
+
// 断言口径:走真实拦截器(只替换 axios adapter,同 contract.test.ts 先例)。
|
|
7
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
8
|
+
import { apiClient } from '../client';
|
|
9
|
+
import { AuthService } from '../../auth/service';
|
|
10
|
+
import { useAuthStore } from '../../auth/store';
|
|
11
|
+
import { API_NAMESPACE } from '../../config';
|
|
12
|
+
|
|
13
|
+
interface Captured {
|
|
14
|
+
url: string;
|
|
15
|
+
headers: Record<string, unknown>;
|
|
16
|
+
params?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let captured: Captured[];
|
|
20
|
+
let originalAdapter: unknown;
|
|
21
|
+
|
|
22
|
+
function installCaptureAdapter() {
|
|
23
|
+
originalAdapter = apiClient.defaults.adapter;
|
|
24
|
+
apiClient.defaults.adapter = (async (config: any) => {
|
|
25
|
+
const headers = config.headers?.toJSON ? config.headers.toJSON() : { ...config.headers };
|
|
26
|
+
captured.push({
|
|
27
|
+
url: String(config.url || ''),
|
|
28
|
+
headers: headers as Record<string, unknown>,
|
|
29
|
+
params: config.params,
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
data: { code: 0, message: 'success' },
|
|
33
|
+
status: 200,
|
|
34
|
+
statusText: 'OK',
|
|
35
|
+
headers: {},
|
|
36
|
+
config,
|
|
37
|
+
};
|
|
38
|
+
}) as any;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function expiredJwt(): string {
|
|
42
|
+
const payload = btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) - 60 }));
|
|
43
|
+
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('U352: 预刷新成功后注入链完整性', () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
captured = [];
|
|
49
|
+
installCaptureAdapter();
|
|
50
|
+
useAuthStore.getState().clearAuth();
|
|
51
|
+
useAuthStore.getState().setAuth(expiredJwt(), 'rt-old', {
|
|
52
|
+
id: 'u-1',
|
|
53
|
+
username: 'demo',
|
|
54
|
+
} as any);
|
|
55
|
+
useAuthStore.getState().setCurrentTenant('tenant-abc');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
apiClient.defaults.adapter = originalAdapter as any;
|
|
60
|
+
vi.restoreAllMocks();
|
|
61
|
+
useAuthStore.getState().clearAuth();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('刷新成功:Authorization=新 token 且租户/namespace/snake 注入不缺席', async () => {
|
|
65
|
+
const refresh = vi.spyOn(AuthService, 'refreshToken').mockResolvedValue('FRESH-TOKEN');
|
|
66
|
+
await apiClient.get('/secret/api/v1/admin/keys', { params: { pageSize: 5 } });
|
|
67
|
+
expect(refresh).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(captured).toHaveLength(1);
|
|
69
|
+
expect(captured[0].headers['Authorization']).toBe('Bearer FRESH-TOKEN');
|
|
70
|
+
expect(captured[0].headers['X-Tenant-ID']).toBe('tenant-abc');
|
|
71
|
+
expect(captured[0].headers['X-Namespace']).toBe(API_NAMESPACE);
|
|
72
|
+
expect(captured[0].params).toEqual({ page_size: 5 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('刷新失败(返回 null):回落旧 token,注入链仍完整', async () => {
|
|
76
|
+
const oldToken = useAuthStore.getState().accessToken!;
|
|
77
|
+
vi.spyOn(AuthService, 'refreshToken').mockResolvedValue(null);
|
|
78
|
+
await apiClient.get('/probe/y');
|
|
79
|
+
expect(captured[0].headers['Authorization']).toBe(`Bearer ${oldToken}`);
|
|
80
|
+
expect(captured[0].headers['X-Tenant-ID']).toBe('tenant-abc');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('刷新抛错:吞错继续(不冒泡),仍携带旧 token 与租户头', async () => {
|
|
84
|
+
vi.spyOn(AuthService, 'refreshToken').mockRejectedValue(new Error('network'));
|
|
85
|
+
await apiClient.get('/probe/z');
|
|
86
|
+
expect(String(captured[0].headers['Authorization'])).toMatch(/^Bearer /);
|
|
87
|
+
expect(captured[0].headers['X-Tenant-ID']).toBe('tenant-abc');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BFF (Backend-For-Frontend) Auth Client
|
|
3
|
+
* 使用 httpOnly session cookie 替代 localStorage token,消除 XSS 令牌窃取风险。
|
|
4
|
+
*
|
|
5
|
+
* BFF 统一路由后:所有 API 通过 /bff/identity/api/v1/auth/* 访问。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const BFF_LOGIN = '/bff/identity/api/v1/auth/login';
|
|
9
|
+
const BFF_REFRESH = '/bff/identity/api/v1/auth/refresh';
|
|
10
|
+
const BFF_LOGOUT = '/bff/identity/api/v1/auth/logout';
|
|
11
|
+
|
|
12
|
+
interface BFFLoginResult {
|
|
13
|
+
code: number;
|
|
14
|
+
message: string;
|
|
15
|
+
data?: {
|
|
16
|
+
user?: { id: string; username?: string; email?: string };
|
|
17
|
+
expires_in?: number;
|
|
18
|
+
requires_mfa?: boolean;
|
|
19
|
+
mfa_check_reason?: string;
|
|
20
|
+
challenge_token?: string;
|
|
21
|
+
must_change_password?: boolean;
|
|
22
|
+
password_warning?: string;
|
|
23
|
+
password_expires_in?: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function bffLogin(
|
|
28
|
+
identity: string,
|
|
29
|
+
password: string,
|
|
30
|
+
tenantId?: string,
|
|
31
|
+
): Promise<BFFLoginResult> {
|
|
32
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
33
|
+
if (tenantId) {
|
|
34
|
+
headers['X-Tenant-ID'] = tenantId;
|
|
35
|
+
}
|
|
36
|
+
const resp = await fetch(BFF_LOGIN, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers,
|
|
39
|
+
body: JSON.stringify({ identity, password, ...(tenantId ? { tenant_id: tenantId } : {}) }),
|
|
40
|
+
});
|
|
41
|
+
if (!resp.ok) {
|
|
42
|
+
return { code: resp.status, message: `BFF unavailable (HTTP ${resp.status})` };
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return await resp.json();
|
|
46
|
+
} catch {
|
|
47
|
+
return { code: -1, message: 'BFF returned invalid response' };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function bffRefresh(): Promise<BFFLoginResult> {
|
|
52
|
+
const resp = await fetch(BFF_REFRESH, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
});
|
|
56
|
+
if (!resp.ok) {
|
|
57
|
+
return { code: resp.status, message: `BFF unavailable (HTTP ${resp.status})` };
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
return await resp.json();
|
|
61
|
+
} catch {
|
|
62
|
+
return { code: -1, message: 'BFF returned invalid response' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function bffLogout(): Promise<void> {
|
|
67
|
+
// BFF cookie 模式未启用时此调用无凭据可依(identity logout 必 401),不发起无意义请求。
|
|
68
|
+
// 导航归调用方(useLogout / useAuthActions.logout 自身跳转),此处只做会话吊销。
|
|
69
|
+
if (!isBFFAvailable()) return;
|
|
70
|
+
await fetch(BFF_LOGOUT, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'Content-Type': 'application/json' },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* BFF 是否可用。
|
|
78
|
+
* 默认基于 __APP_CONFIG__ 中的 VITE_BFF_ENABLED 环境变量。
|
|
79
|
+
* 也可以在调用前通过 isBFFAvailable.override 设置(用于测试或动态切换)。
|
|
80
|
+
*/
|
|
81
|
+
export function isBFFAvailable(): boolean {
|
|
82
|
+
if (typeof window !== 'undefined') {
|
|
83
|
+
const cfg = (window as any).__APP_CONFIG__;
|
|
84
|
+
if (cfg?.VITE_BFF_ENABLED === 'true') return true;
|
|
85
|
+
if (cfg?.VITE_BFF_ENABLED === 'false') return false;
|
|
86
|
+
}
|
|
87
|
+
// Static override (for testing or dynamic switching)
|
|
88
|
+
if (isBFFAvailable._override !== undefined) return isBFFAvailable._override;
|
|
89
|
+
// Default: false (auth-pages opt-in via env var)
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Allow runtime override (e.g. for testing or feature-flag driven toggling)
|
|
94
|
+
isBFFAvailable._override = undefined as boolean | undefined;
|