@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,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autional 统一 API Client
|
|
3
|
+
* - 所有前端应用共用同一个 axios 实例
|
|
4
|
+
* - 自动注入 Bearer token + X-Tenant-ID
|
|
5
|
+
* - 自动 unwrap 后端响应 + PascalCase→camelCase 转换
|
|
6
|
+
* - 自动 token refresh + 401 处理
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import axios, { AxiosError } from 'axios';
|
|
10
|
+
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
|
|
11
|
+
import { camelCaseKeys, snakeCaseKeys } from '../utils/case';
|
|
12
|
+
import { API_NAMESPACE, getApiBaseUrl } from '../config';
|
|
13
|
+
import { getTraceParentHeader } from './trace';
|
|
14
|
+
import { ENTRY_PLANE_HEADER, resolveEntryPlane } from './entry-plane';
|
|
15
|
+
import { NAMESPACE_HEADER, needsNamespaceHeader } from './namespace';
|
|
16
|
+
import { AuthService } from '../auth/service';
|
|
17
|
+
|
|
18
|
+
function setupInterceptors(instance: AxiosInstance) {
|
|
19
|
+
// ============ Request Interceptor ============
|
|
20
|
+
instance.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
|
21
|
+
// 预判式 Token 刷新:如果 Token 已过期或即将过期(60s 缓冲),先刷新再发请求
|
|
22
|
+
// U352:刷新成功只替换本次 bearer —— 不得提前 return,否则跳过后方全部注入
|
|
23
|
+
// (X-Tenant-ID / namespace / body·params snakeCase / entry-plane / traceparent)。
|
|
24
|
+
const token = AuthService.getAccessToken();
|
|
25
|
+
let bearer = token;
|
|
26
|
+
if (token && AuthService.isTokenExpired(token)) {
|
|
27
|
+
try {
|
|
28
|
+
const newToken = await AuthService.refreshToken();
|
|
29
|
+
if (newToken) bearer = newToken;
|
|
30
|
+
} catch (e) {
|
|
31
|
+
console.warn('[API] token pre-refresh failed', (e as Error)?.message || '');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (bearer) {
|
|
35
|
+
config.headers.Authorization = `Bearer ${bearer}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tenantId = AuthService.getCurrentTenantId();
|
|
39
|
+
if (tenantId) {
|
|
40
|
+
config.headers['X-Tenant-ID'] = tenantId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// secret 管理面(§13-A P1-B):admin 组端点 fail-closed 要求 X-Namespace ∈ S
|
|
44
|
+
if (needsNamespaceHeader(config.url)) {
|
|
45
|
+
config.headers[NAMESPACE_HEADER] = API_NAMESPACE;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 请求体 camelCase → snake_case 转换(后端 Go 期望 snake_case)
|
|
49
|
+
// 【契约指针】请求参数/体构造一律用 camel 书面写,键名转换由本拦截器承担;
|
|
50
|
+
// 分页参数请经 shared `toPageParams`(utils/page.ts)构造,勿手写 snake 键字面量。
|
|
51
|
+
if (config.data && typeof config.data === 'object' && !(config.data instanceof FormData)) {
|
|
52
|
+
config.data = snakeCaseKeys(config.data);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 查询参数 camelCase → snake_case 转换(后端 Go 期望 snake_case form tag)
|
|
56
|
+
if (config.params && typeof config.params === 'object') {
|
|
57
|
+
config.params = snakeCaseKeys(config.params);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 按入口登录(P5):登录端点声明入口平面,identity 据此签发 entry_plane claim
|
|
61
|
+
// (admin./platform. 门户的 redirect 目标决定平面;缺省 api)
|
|
62
|
+
if (config.url?.includes('/api/v1/auth/login') && typeof window !== 'undefined') {
|
|
63
|
+
const redirect = new URLSearchParams(window.location.search).get('redirect');
|
|
64
|
+
config.headers[ENTRY_PLANE_HEADER] = resolveEntryPlane(redirect, window.location.hostname);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const traceparent = getTraceParentHeader();
|
|
68
|
+
if (traceparent) {
|
|
69
|
+
config.headers['traceparent'] = traceparent;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return config;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ============ Response Interceptor ============
|
|
76
|
+
instance.interceptors.response.use(
|
|
77
|
+
(res) => {
|
|
78
|
+
const data = res.data;
|
|
79
|
+
|
|
80
|
+
// 只处理后端标准响应格式 { code, message, ... }
|
|
81
|
+
if (data && typeof data === 'object' && 'code' in data) {
|
|
82
|
+
let payload: unknown;
|
|
83
|
+
|
|
84
|
+
// 嵌套 data 格式: { code, message, data: {...}, timestamp }
|
|
85
|
+
if ('data' in data && data.data !== undefined) {
|
|
86
|
+
payload = data.data;
|
|
87
|
+
}
|
|
88
|
+
// 扁平列表格式: { code, message, items, total, pagination, timestamp }
|
|
89
|
+
else if ('items' in data) {
|
|
90
|
+
payload = {
|
|
91
|
+
items: data.items,
|
|
92
|
+
total: data.total,
|
|
93
|
+
pagination: data.pagination,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// 无 data/items 的简单响应 (如删除成功)
|
|
97
|
+
else {
|
|
98
|
+
payload = data;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// PascalCase → camelCase 转换
|
|
102
|
+
// 【契约指针】响应键名转换单点在拦截器;页面读取形状请用
|
|
103
|
+
// `extractList` / `extractItem`(唯一形状适配点,utils/response.ts),
|
|
104
|
+
// 分页归一用 `fromPageResult`(utils/page.ts)——勿在页面里手写转换/解包。
|
|
105
|
+
res.data = camelCaseKeys(payload);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return res;
|
|
109
|
+
},
|
|
110
|
+
async (err: AxiosError) => {
|
|
111
|
+
const originalRequest = err.config as InternalAxiosRequestConfig & {
|
|
112
|
+
_retry?: boolean;
|
|
113
|
+
_tenantRetry?: boolean;
|
|
114
|
+
};
|
|
115
|
+
if (!originalRequest) return Promise.reject(err);
|
|
116
|
+
|
|
117
|
+
// 401 → token refresh (skip for login & refresh endpoints — no token to refresh / avoid loops)
|
|
118
|
+
// Also skip OAuth token-exchange: a 401 there is a business error (invalid_grant / invalid_client),
|
|
119
|
+
// NOT session expiry. Treating it as one routes through refreshToken() → onUnauthorized → login
|
|
120
|
+
// page bounce while the auth code is already consumed → infinite redirect loop.
|
|
121
|
+
if (
|
|
122
|
+
err.response?.status === 401 &&
|
|
123
|
+
!originalRequest._retry &&
|
|
124
|
+
!originalRequest.url?.includes('/api/v1/auth/login') &&
|
|
125
|
+
!originalRequest.url?.includes('/api/v1/auth/refresh') &&
|
|
126
|
+
!originalRequest.url?.includes('/oauth/api/v1/oauth/token') &&
|
|
127
|
+
// oauth refresh 端点自身的 401(无效/过期 refresh token)是业务性失败,
|
|
128
|
+
// 不走 refresh 重试(避免 oauth refresh → 401 → 再 refresh 的循环)
|
|
129
|
+
!originalRequest.url?.includes('/oauth/api/v1/oauth/refresh') &&
|
|
130
|
+
// P1-4: export-data 的业务性 401(step-up 拒绝)不是会话过期,
|
|
131
|
+
// 不走 refresh 重试/登出,由页面 catch 展示错误。
|
|
132
|
+
!originalRequest.url?.includes('/auth/me/export-data') &&
|
|
133
|
+
// AUTH-42: delete-account 的 401 是业务性失败(step-up 缺失/过期 40800251、
|
|
134
|
+
// 密码错误 40000502),不是会话过期;走 refresh 重试会掩盖真因(审计现场
|
|
135
|
+
// 即 401→refresh 200→重试 401 双跳后误报「请检查密码是否正确」)。
|
|
136
|
+
!originalRequest.url?.includes('/auth/me/delete-account') &&
|
|
137
|
+
// re-authenticate 同理:密码错误 401(40000502)须由页面按 code 分流文案。
|
|
138
|
+
!originalRequest.url?.includes('/auth/re-authenticate')
|
|
139
|
+
) {
|
|
140
|
+
originalRequest._retry = true;
|
|
141
|
+
|
|
142
|
+
// 尝试刷新 token(refreshToken 内部处理无 refresh token / 刷新失败 / onUnauthorized)
|
|
143
|
+
const newToken = await AuthService.refreshToken();
|
|
144
|
+
if (newToken) {
|
|
145
|
+
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
|
146
|
+
return instance(originalRequest);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 刷新失败 → 已由 refreshToken 内部调用 onUnauthorized
|
|
150
|
+
return Promise.reject(err);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 404 + tenant not found (ErrCodeDomainTenantNotFound=40000102)
|
|
154
|
+
// → clear tenant from store → retry without X-Tenant-ID
|
|
155
|
+
const data = err.response?.data as any;
|
|
156
|
+
if (
|
|
157
|
+
err.response?.status === 404 &&
|
|
158
|
+
data?.code === '40000102' &&
|
|
159
|
+
!originalRequest._tenantRetry
|
|
160
|
+
) {
|
|
161
|
+
originalRequest._tenantRetry = true;
|
|
162
|
+
AuthService.updateCurrentTenant('');
|
|
163
|
+
// Remove X-Tenant-ID header and retry
|
|
164
|
+
const headers = originalRequest.headers as Record<string, unknown>;
|
|
165
|
+
delete headers['X-Tenant-ID'];
|
|
166
|
+
return instance(originalRequest);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return Promise.reject(err);
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 创建自定义 baseURL 的 API client 实例
|
|
176
|
+
* 适用于需要访问多个不同服务前缀的场景(如 admin-console)
|
|
177
|
+
*/
|
|
178
|
+
export function createApiClient(baseUrl: string): AxiosInstance {
|
|
179
|
+
const instance = axios.create({
|
|
180
|
+
baseURL: baseUrl,
|
|
181
|
+
timeout: 30000,
|
|
182
|
+
headers: { 'Content-Type': 'application/json' },
|
|
183
|
+
});
|
|
184
|
+
setupInterceptors(instance);
|
|
185
|
+
return instance;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 默认 API client 实例,baseURL 从 API_BASE_URL 配置读取,fallback '/api/v1'
|
|
190
|
+
*/
|
|
191
|
+
export const apiClient: AxiosInstance = createApiClient(getApiBaseUrl());
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 按入口登录:登录请求的入口平面声明(GATEWAY-ENTRY-ISOLATION-DESIGN §4.4)。
|
|
3
|
+
* 值域 api|admin|platform(identity entry_plane.go 契约);仅登录端点消费,
|
|
4
|
+
* refresh/challenge 由服务端从 claim 继承,无需前端声明。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type EntryPlane = 'api' | 'admin' | 'platform';
|
|
8
|
+
|
|
9
|
+
export const ENTRY_PLANE_HEADER = 'X-Entry-Plane';
|
|
10
|
+
|
|
11
|
+
function planeFromHostname(hostname: string): EntryPlane | null {
|
|
12
|
+
const h = hostname.toLowerCase();
|
|
13
|
+
if (h === 'admin' || h.startsWith('admin.')) return 'admin';
|
|
14
|
+
// security.<root>(安全仪表盘/SOC 门户)API 面为 admin 受众(风险看板等 admin 路由),
|
|
15
|
+
// 与 admin 门户同平面(GATEWAY-ENTRY-ISOLATION-DESIGN §3.2/§4.4)。
|
|
16
|
+
if (h === 'security' || h.startsWith('security.')) return 'admin';
|
|
17
|
+
if (h === 'platform' || h.startsWith('platform.')) return 'platform';
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 授权流下钻:同意页冷启动时(无会话),登录页的 redirect 参数是 authorize URL 本身
|
|
23
|
+
* (`<slug>/login?redirect=<authorize URL>`,oauth-cold-start/TASK-07),其宿主是 auth
|
|
24
|
+
* 门户 ⇒ 宿主判定为空。发起门户嵌在其查询参数里:`redirect_uri`(发起方回调,宿主即
|
|
25
|
+
* 门户)或 `state.redirect`(发起页 URL,见 oauth-login state 载荷)。不下钻则回程
|
|
26
|
+
* 门户拿到的 token 平面恒为 api,admin/platform 受众端点全部 403 40000503(F-W5)。
|
|
27
|
+
*/
|
|
28
|
+
function planeFromAuthorizeRequest(url: URL): EntryPlane | null {
|
|
29
|
+
if (!url.pathname.includes('/oauth/authorize')) return null;
|
|
30
|
+
const candidates: Array<string | null> = [url.searchParams.get('redirect_uri')];
|
|
31
|
+
try {
|
|
32
|
+
const state = JSON.parse(url.searchParams.get('state') || '');
|
|
33
|
+
if (state && typeof state.redirect === 'string') candidates.push(state.redirect);
|
|
34
|
+
} catch {
|
|
35
|
+
// state 非 JSON(旧式纯串)→ 唯一候选 redirect_uri
|
|
36
|
+
}
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
if (!candidate) continue;
|
|
39
|
+
try {
|
|
40
|
+
const plane = planeFromHostname(new URL(candidate, url.origin).hostname);
|
|
41
|
+
if (plane) return plane;
|
|
42
|
+
} catch {
|
|
43
|
+
// 单个候选非法不阻断后续候选
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 由登录页 redirect 参数目标(或当前主机)推导入口平面,无法判定时缺省 api。 */
|
|
50
|
+
export function resolveEntryPlane(redirect: string | null, currentHostname: string): EntryPlane {
|
|
51
|
+
if (redirect) {
|
|
52
|
+
try {
|
|
53
|
+
const url = new URL(redirect, `https://${currentHostname || 'localhost'}`);
|
|
54
|
+
const fromTarget = planeFromHostname(url.hostname) ?? planeFromAuthorizeRequest(url);
|
|
55
|
+
if (fromTarget) return fromTarget;
|
|
56
|
+
} catch {
|
|
57
|
+
// redirect 非法时回落当前主机判定
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return planeFromHostname(currentHostname) ?? 'api';
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* secret 服务管理面 namespace(§13-A P1-B / SEC-204/207):
|
|
3
|
+
* `/secret/api/v1/admin*` 端点 fail-closed 要求 `X-Namespace ∈ 实例服务集合 S`
|
|
4
|
+
* (NamespaceMiddleware,缺头/未服务 → 400)。值取部署配置 VITE_API_NAMESPACE
|
|
5
|
+
* (见 config,缺省 "dev" 与服务端 DefaultNamespace 对称)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const NAMESPACE_HEADER = 'X-Namespace';
|
|
9
|
+
|
|
10
|
+
export const SECRET_ADMIN_PATH_PREFIX = '/secret/api/v1/admin';
|
|
11
|
+
|
|
12
|
+
export function needsNamespaceHeader(url?: string): boolean {
|
|
13
|
+
return !!url && url.includes(SECRET_ADMIN_PATH_PREFIX);
|
|
14
|
+
}
|
package/src/api/trace.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const traceparentProviders: Array<() => string | undefined> = [];
|
|
2
|
+
|
|
3
|
+
export function setTraceParentProvider(provider: () => string | undefined): void {
|
|
4
|
+
traceparentProviders.push(provider);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function getTraceParentHeader(): string | undefined {
|
|
8
|
+
for (const provider of traceparentProviders) {
|
|
9
|
+
const tp = provider();
|
|
10
|
+
if (tp) return tp;
|
|
11
|
+
}
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resetSessionTraceId(): void {
|
|
16
|
+
// trace ID is managed by the OTel bridge
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function enableOtelBridge(): void {
|
|
20
|
+
// No-op stub — requires @opentelemetry/api dependency
|
|
21
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { useAuthStore, getCurrentRole } from '../store';
|
|
3
|
+
|
|
4
|
+
// 注:node 测试环境下 zustand persist 中间件会打印一条无害的 storage 降级警告
|
|
5
|
+
// ("Unable to update item 'autional-auth-v1'"),这是 store.ts 既有 persist 的
|
|
6
|
+
// 标准降级行为,不影响被测的 getCurrentRole 派生逻辑。
|
|
7
|
+
|
|
8
|
+
const TENANTS = [
|
|
9
|
+
{ id: 'tenant-a', name: 'Tenant A', role: 'admin' },
|
|
10
|
+
{ id: 'tenant-b', name: 'Tenant B', role: 'member' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 构造最小 JWT(base64url header.payload.signature)。
|
|
15
|
+
* 仅用于测试「前端不再读取 token 载荷」——getCurrentRole 只读 store,
|
|
16
|
+
* 因此 signature 内容无关紧要。
|
|
17
|
+
*/
|
|
18
|
+
function buildJwt(payload: Record<string, unknown>): string {
|
|
19
|
+
const enc = (obj: unknown) =>
|
|
20
|
+
btoa(JSON.stringify(obj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
21
|
+
return `${enc({ alg: 'none', typ: 'JWT' })}.${enc(payload)}.test-signature`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 重置 store 到初始状态,避免跨用例污染 */
|
|
25
|
+
function resetAuthStore(): void {
|
|
26
|
+
useAuthStore.setState({
|
|
27
|
+
user: null,
|
|
28
|
+
accessToken: null,
|
|
29
|
+
refreshToken: null,
|
|
30
|
+
tenants: [],
|
|
31
|
+
currentTenantId: null,
|
|
32
|
+
permissions: [],
|
|
33
|
+
isAuthenticated: false,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('AC-007 密码/OAuth 双路径行为一致(userinfo-first,角色来自 store)', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
resetAuthStore();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('密码场景:token 带 custom.role 但前端不读 → 返回 store 角色', () => {
|
|
43
|
+
// 密码登录 token 载荷带 custom.role(旧解析来源),但前端已不再读取
|
|
44
|
+
const passwordToken = buildJwt({
|
|
45
|
+
sub: 'user-1',
|
|
46
|
+
custom: { role: 'super_admin', username: 'pw-user' },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// 角色真正来源:loadAuthExtras 写入的 store
|
|
50
|
+
useAuthStore.setState({
|
|
51
|
+
accessToken: passwordToken,
|
|
52
|
+
tenants: TENANTS,
|
|
53
|
+
currentTenantId: 'tenant-a',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// 若前端仍解析 token 会得到 super_admin;正确行为是返回 store 角色 admin
|
|
57
|
+
expect(getCurrentRole()).toBe('admin');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('OAuth 场景:token 无 role + 相同 store → 结果与密码场景一致', () => {
|
|
61
|
+
// OAuth/挂载路径 token 载荷无 role(旧解析无角色来源)
|
|
62
|
+
const oauthToken = buildJwt({ sub: 'user-1' });
|
|
63
|
+
|
|
64
|
+
useAuthStore.setState({
|
|
65
|
+
accessToken: oauthToken,
|
|
66
|
+
tenants: TENANTS,
|
|
67
|
+
currentTenantId: 'tenant-a',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(getCurrentRole()).toBe('admin');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('双路径矩阵:两场景结果相等(store 预填充为唯一角色来源)', () => {
|
|
74
|
+
const passwordToken = buildJwt({
|
|
75
|
+
sub: 'user-1',
|
|
76
|
+
custom: { role: 'super_admin', username: 'pw-user' },
|
|
77
|
+
});
|
|
78
|
+
const oauthToken = buildJwt({ sub: 'user-1' });
|
|
79
|
+
|
|
80
|
+
// 相同 store 预填充
|
|
81
|
+
useAuthStore.setState({
|
|
82
|
+
accessToken: passwordToken,
|
|
83
|
+
tenants: TENANTS,
|
|
84
|
+
currentTenantId: 'tenant-a',
|
|
85
|
+
});
|
|
86
|
+
const roleFromPassword = getCurrentRole();
|
|
87
|
+
|
|
88
|
+
useAuthStore.setState({
|
|
89
|
+
accessToken: oauthToken,
|
|
90
|
+
tenants: TENANTS,
|
|
91
|
+
currentTenantId: 'tenant-a',
|
|
92
|
+
});
|
|
93
|
+
const roleFromOAuth = getCurrentRole();
|
|
94
|
+
|
|
95
|
+
expect(roleFromPassword).toBe(roleFromOAuth);
|
|
96
|
+
expect(roleFromPassword).toBe('admin');
|
|
97
|
+
expect(roleFromOAuth).toBe('admin');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('token 携带不同 custom.role 时 store 角色仍胜出(证明零 token 载荷读取)', () => {
|
|
101
|
+
const adminToken = buildJwt({
|
|
102
|
+
sub: 'user-1',
|
|
103
|
+
custom: { role: 'super_admin' },
|
|
104
|
+
});
|
|
105
|
+
const memberToken = buildJwt({
|
|
106
|
+
sub: 'user-1',
|
|
107
|
+
custom: { role: 'member' },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
useAuthStore.setState({
|
|
111
|
+
accessToken: adminToken,
|
|
112
|
+
tenants: TENANTS,
|
|
113
|
+
currentTenantId: 'tenant-a',
|
|
114
|
+
});
|
|
115
|
+
expect(getCurrentRole()).toBe('admin'); // store 角色,非 token 的 super_admin
|
|
116
|
+
|
|
117
|
+
useAuthStore.setState({
|
|
118
|
+
accessToken: memberToken,
|
|
119
|
+
tenants: TENANTS,
|
|
120
|
+
currentTenantId: 'tenant-a',
|
|
121
|
+
});
|
|
122
|
+
expect(getCurrentRole()).toBe('admin'); // token role 变化不影响 store 派生
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthMachine 单元测试
|
|
3
|
+
*
|
|
4
|
+
* 验证状态机 reducer 的纯函数逻辑:
|
|
5
|
+
* - 状态转换是否正确
|
|
6
|
+
* - 边界条件
|
|
7
|
+
* - 从不合法状态恢复
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
|
|
11
|
+
// AuthMachine 的 reducer 逻辑是纯函数,可以直接测试
|
|
12
|
+
type AuthStatus =
|
|
13
|
+
| 'checking'
|
|
14
|
+
| 'authenticated'
|
|
15
|
+
| 'bootstrap'
|
|
16
|
+
| 'ready'
|
|
17
|
+
| 'unauthenticated'
|
|
18
|
+
| 'redirecting';
|
|
19
|
+
|
|
20
|
+
interface MachineState {
|
|
21
|
+
status: AuthStatus;
|
|
22
|
+
hasToken: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Action =
|
|
26
|
+
| { type: 'TOKEN_FOUND' }
|
|
27
|
+
| { type: 'NO_TOKEN' }
|
|
28
|
+
| { type: 'BOOTSTRAP_LOADING' }
|
|
29
|
+
| { type: 'BOOTSTRAP_READY' }
|
|
30
|
+
| { type: 'BOOTSTRAP_ERROR' }
|
|
31
|
+
| { type: 'SESSION_EXPIRED' }
|
|
32
|
+
| { type: 'REDIRECT' };
|
|
33
|
+
|
|
34
|
+
function authReducer(state: MachineState, action: Action): MachineState {
|
|
35
|
+
switch (action.type) {
|
|
36
|
+
case 'TOKEN_FOUND':
|
|
37
|
+
return { ...state, status: 'authenticated', hasToken: true };
|
|
38
|
+
case 'NO_TOKEN':
|
|
39
|
+
return { ...state, status: 'unauthenticated', hasToken: false };
|
|
40
|
+
case 'BOOTSTRAP_LOADING':
|
|
41
|
+
return state.status === 'authenticated' ? { ...state, status: 'bootstrap' } : state;
|
|
42
|
+
case 'BOOTSTRAP_READY':
|
|
43
|
+
return state.status === 'bootstrap' || state.status === 'authenticated'
|
|
44
|
+
? { ...state, status: 'ready' }
|
|
45
|
+
: state;
|
|
46
|
+
case 'BOOTSTRAP_ERROR':
|
|
47
|
+
return state.status === 'bootstrap' ? { ...state, status: 'ready' } : state;
|
|
48
|
+
case 'SESSION_EXPIRED':
|
|
49
|
+
return { ...state, status: 'unauthenticated', hasToken: false };
|
|
50
|
+
case 'REDIRECT':
|
|
51
|
+
return { ...state, status: 'redirecting' };
|
|
52
|
+
default:
|
|
53
|
+
return state;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function createInitialState(): MachineState {
|
|
58
|
+
return { status: 'checking', hasToken: false };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('AuthMachine reducer', () => {
|
|
62
|
+
it('初始状态为 checking,无 token', () => {
|
|
63
|
+
const state = createInitialState();
|
|
64
|
+
expect(state.status).toBe('checking');
|
|
65
|
+
expect(state.hasToken).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('TOKEN_FOUND → authenticated', () => {
|
|
69
|
+
const state = authReducer(createInitialState(), { type: 'TOKEN_FOUND' });
|
|
70
|
+
expect(state.status).toBe('authenticated');
|
|
71
|
+
expect(state.hasToken).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('NO_TOKEN → unauthenticated', () => {
|
|
75
|
+
const state = authReducer(createInitialState(), { type: 'NO_TOKEN' });
|
|
76
|
+
expect(state.status).toBe('unauthenticated');
|
|
77
|
+
expect(state.hasToken).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('authenticated + BOOTSTRAP_LOADING → bootstrap', () => {
|
|
81
|
+
const authenticated = authReducer(createInitialState(), { type: 'TOKEN_FOUND' });
|
|
82
|
+
const state = authReducer(authenticated, { type: 'BOOTSTRAP_LOADING' });
|
|
83
|
+
expect(state.status).toBe('bootstrap');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('authenticated + BOOTSTRAP_READY → ready(跳过 bootstrap)', () => {
|
|
87
|
+
const authenticated = authReducer(createInitialState(), { type: 'TOKEN_FOUND' });
|
|
88
|
+
const state = authReducer(authenticated, { type: 'BOOTSTRAP_READY' });
|
|
89
|
+
expect(state.status).toBe('ready');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('bootstrap + BOOTSTRAP_READY → ready', () => {
|
|
93
|
+
const authenticated = authReducer(createInitialState(), { type: 'TOKEN_FOUND' });
|
|
94
|
+
const bootstrap = authReducer(authenticated, { type: 'BOOTSTRAP_LOADING' });
|
|
95
|
+
const state = authReducer(bootstrap, { type: 'BOOTSTRAP_READY' });
|
|
96
|
+
expect(state.status).toBe('ready');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('bootstrap + BOOTSTRAP_ERROR → ready(降级运行)', () => {
|
|
100
|
+
const authenticated = authReducer(createInitialState(), { type: 'TOKEN_FOUND' });
|
|
101
|
+
const bootstrap = authReducer(authenticated, { type: 'BOOTSTRAP_LOADING' });
|
|
102
|
+
const state = authReducer(bootstrap, { type: 'BOOTSTRAP_ERROR' });
|
|
103
|
+
expect(state.status).toBe('ready');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('SESSION_EXPIRED → unauthenticated(从任何状态)', () => {
|
|
107
|
+
const ready = authReducer(
|
|
108
|
+
authReducer(authReducer(createInitialState(), { type: 'TOKEN_FOUND' }), {
|
|
109
|
+
type: 'BOOTSTRAP_READY',
|
|
110
|
+
}),
|
|
111
|
+
{ type: 'SESSION_EXPIRED' },
|
|
112
|
+
);
|
|
113
|
+
expect(ready.status).toBe('unauthenticated');
|
|
114
|
+
expect(ready.hasToken).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('unauthenticated + REDIRECT → redirecting', () => {
|
|
118
|
+
const unauth = authReducer(createInitialState(), { type: 'NO_TOKEN' });
|
|
119
|
+
const state = authReducer(unauth, { type: 'REDIRECT' });
|
|
120
|
+
expect(state.status).toBe('redirecting');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('checking 态下 BOOTSTRAP_LOADING 不应改变状态', () => {
|
|
124
|
+
const state = authReducer(createInitialState(), { type: 'BOOTSTRAP_LOADING' });
|
|
125
|
+
expect(state.status).toBe('checking'); // 不变
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('checking 态下 BOOTSTRAP_READY 不应改变状态', () => {
|
|
129
|
+
const state = authReducer(createInitialState(), { type: 'BOOTSTRAP_READY' });
|
|
130
|
+
expect(state.status).toBe('checking'); // 不变
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('完整 happy path: checking → authenticated → bootstrap → ready', () => {
|
|
134
|
+
let state = createInitialState();
|
|
135
|
+
expect(state.status).toBe('checking');
|
|
136
|
+
|
|
137
|
+
state = authReducer(state, { type: 'TOKEN_FOUND' });
|
|
138
|
+
expect(state.status).toBe('authenticated');
|
|
139
|
+
|
|
140
|
+
state = authReducer(state, { type: 'BOOTSTRAP_LOADING' });
|
|
141
|
+
expect(state.status).toBe('bootstrap');
|
|
142
|
+
|
|
143
|
+
state = authReducer(state, { type: 'BOOTSTRAP_READY' });
|
|
144
|
+
expect(state.status).toBe('ready');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('完整 unauthenticated path: checking → unauthenticated → redirecting', () => {
|
|
148
|
+
let state = createInitialState();
|
|
149
|
+
expect(state.status).toBe('checking');
|
|
150
|
+
|
|
151
|
+
state = authReducer(state, { type: 'NO_TOKEN' });
|
|
152
|
+
expect(state.status).toBe('unauthenticated');
|
|
153
|
+
|
|
154
|
+
state = authReducer(state, { type: 'REDIRECT' });
|
|
155
|
+
expect(state.status).toBe('redirecting');
|
|
156
|
+
});
|
|
157
|
+
});
|