@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.
Files changed (115) hide show
  1. package/LICENSE +661 -0
  2. package/package.json +49 -3
  3. package/src/api/__tests__/contract.test.ts +172 -0
  4. package/src/api/__tests__/entry-plane.test.ts +119 -0
  5. package/src/api/__tests__/namespace.test.ts +27 -0
  6. package/src/api/__tests__/preflight-refresh.test.ts +89 -0
  7. package/src/api/bff-client.ts +94 -0
  8. package/src/api/client.ts +191 -0
  9. package/src/api/entry-plane.ts +61 -0
  10. package/src/api/namespace.ts +14 -0
  11. package/src/api/trace.ts +21 -0
  12. package/src/auth/__tests__/ac007-role-source.test.ts +124 -0
  13. package/src/auth/__tests__/auth-machine.test.ts +157 -0
  14. package/src/auth/__tests__/auth-trace.test.ts +321 -0
  15. package/src/auth/__tests__/build-login-url.test.ts +259 -0
  16. package/src/auth/__tests__/get-current-role.test.ts +61 -0
  17. package/src/auth/__tests__/jwt-payload.test.ts +49 -0
  18. package/src/auth/__tests__/logout-redirect.test.ts +44 -0
  19. package/src/auth/__tests__/logout-revoke.test.ts +219 -0
  20. package/src/auth/__tests__/oauth-callback-error-codes.test.ts +69 -0
  21. package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
  22. package/src/auth/__tests__/oauth-initiate-stay.test.ts +118 -0
  23. package/src/auth/__tests__/oauth-login-user-resolution.test.ts +185 -0
  24. package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
  25. package/src/auth/__tests__/session-client-id.test.ts +147 -0
  26. package/src/auth/__tests__/session-terminal-redirect.test.ts +138 -0
  27. package/src/auth/__tests__/slug-from-url.test.ts +112 -0
  28. package/src/auth/auth-machine.ts +151 -0
  29. package/src/auth/auth-queries.ts +118 -0
  30. package/src/auth/auth-trace.ts +511 -0
  31. package/src/auth/build-tenant-urls.ts +48 -0
  32. package/src/auth/jwt-payload.ts +21 -0
  33. package/src/auth/oauth-client-from-slug.ts +119 -0
  34. package/src/auth/oauth-client-id-store.ts +41 -0
  35. package/src/auth/oauth-login.ts +388 -0
  36. package/src/auth/pkce.ts +141 -0
  37. package/src/auth/portal-config.ts +68 -0
  38. package/src/auth/roles.ts +120 -0
  39. package/src/auth/service.ts +433 -0
  40. package/src/auth/slug-from-url.ts +92 -0
  41. package/src/auth/store.ts +227 -0
  42. package/src/auth/tenant-route-middleware.ts +113 -0
  43. package/src/auth/tenant-slug-context.tsx +9 -0
  44. package/src/branding/BrandingInitializer.tsx +60 -0
  45. package/src/branding/__tests__/brand-vars.test.ts +126 -0
  46. package/src/branding/brand-color.ts +193 -0
  47. package/src/branding/branding-cache.ts +71 -0
  48. package/src/branding/extract-branding.ts +27 -0
  49. package/src/branding/index.ts +15 -0
  50. package/src/branding/tenant-store.ts +13 -0
  51. package/src/branding/types.ts +14 -0
  52. package/src/branding/useBranding.ts +101 -0
  53. package/src/components/AuditStatsOnly.tsx +65 -0
  54. package/src/components/OAuthCallbackPage.tsx +83 -0
  55. package/src/components/PortalGuard.tsx +105 -0
  56. package/src/components/RequireAuth.tsx +220 -0
  57. package/src/components/TenantIndexGuard.tsx +131 -0
  58. package/src/components/TenantRootRedirect.tsx +49 -0
  59. package/src/components/__tests__/RequireAuth.adversarial.test.tsx +165 -0
  60. package/src/components/__tests__/RequireAuth.stay.test.tsx +131 -0
  61. package/src/components/__tests__/RequireAuth.test.tsx +296 -0
  62. package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
  63. package/src/components/__tests__/TenantRootRedirect.test.tsx +180 -0
  64. package/src/config/__tests__/portal-resolver.test.ts +302 -0
  65. package/src/config/__tests__/valid-redirect.test.ts +47 -0
  66. package/src/config/constants.ts +27 -0
  67. package/src/config/index.ts +426 -0
  68. package/src/config/resolve-basename.ts +57 -0
  69. package/src/constants/api-paths.ts +450 -0
  70. package/src/generated/api.ts +13012 -0
  71. package/src/generated/queries.ts +12984 -0
  72. package/src/generated/types.ts +13982 -0
  73. package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
  74. package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
  75. package/src/hooks/__tests__/useCurrentTenantIdOr.test.tsx +38 -0
  76. package/src/hooks/__tests__/useLogout.test.tsx +61 -0
  77. package/src/hooks/__tests__/usePermission.map.test.tsx +156 -0
  78. package/src/hooks/__tests__/usePortalCatalog.test.tsx +301 -0
  79. package/src/hooks/__tests__/useSodMode.test.tsx +205 -0
  80. package/src/hooks/useAuth.ts +239 -0
  81. package/src/hooks/useBootstrap.ts +74 -0
  82. package/src/hooks/useCurrentRole.ts +18 -0
  83. package/src/hooks/useCurrentTenantIdOr.ts +29 -0
  84. package/src/hooks/useLogout.ts +42 -0
  85. package/src/hooks/usePermission.ts +128 -0
  86. package/src/hooks/usePortalCatalog.ts +199 -0
  87. package/src/hooks/useSodMode.ts +81 -0
  88. package/src/index.ts +247 -0
  89. package/src/lib/__tests__/push.test.ts +30 -0
  90. package/src/lib/push.ts +44 -0
  91. package/src/seo/dom-utils.ts +94 -0
  92. package/src/seo/index.ts +6 -0
  93. package/src/seo/jsonld.ts +29 -0
  94. package/src/seo/types.ts +29 -0
  95. package/src/seo/usePageMeta.ts +17 -0
  96. package/src/seo/usePageTitle.ts +14 -0
  97. package/src/seo/useSEO.ts +80 -0
  98. package/src/types/index.ts +190 -0
  99. package/src/utils/__tests__/case.test.ts +94 -0
  100. package/src/utils/__tests__/error.test.ts +72 -0
  101. package/src/utils/__tests__/mock-test.ts +15 -0
  102. package/src/utils/__tests__/page.test.ts +54 -0
  103. package/src/utils/__tests__/query-state.test.ts +120 -0
  104. package/src/utils/browser.ts +15 -0
  105. package/src/utils/case.ts +59 -0
  106. package/src/utils/error.ts +35 -0
  107. package/src/utils/format.ts +63 -0
  108. package/src/utils/page.ts +57 -0
  109. package/src/utils/password-transmission.test.ts +145 -0
  110. package/src/utils/password-transmission.ts +253 -0
  111. package/src/utils/query-state.ts +85 -0
  112. package/src/utils/response.ts +71 -0
  113. package/src/utils/vite.ts +4 -0
  114. package/src/vite-env.d.ts +19 -0
  115. package/README.md +0 -3
@@ -0,0 +1,6 @@
1
+ export { useSEO } from './useSEO';
2
+ export { usePageTitle } from './usePageTitle';
3
+ export { usePageMeta } from './usePageMeta';
4
+ export { getArticleSchema } from './jsonld';
5
+ export { upsertMeta, upsertCanonical, upsertLink, injectJSONLD } from './dom-utils';
6
+ export type { SEOConfig, SiteSEOConfig, BlogPostSchema } from './types';
@@ -0,0 +1,29 @@
1
+ import type { BlogPostSchema } from './types';
2
+
3
+ export function getArticleSchema(post: BlogPostSchema, baseBlogUrl: string) {
4
+ const article: Record<string, unknown> = {
5
+ '@context': 'https://schema.org',
6
+ '@type': 'BlogPosting',
7
+ headline: post.title,
8
+ description: post.excerpt,
9
+ datePublished: post.date,
10
+ url: `${baseBlogUrl}/${post.slug}`,
11
+ };
12
+
13
+ if (post.author) {
14
+ article.author = {
15
+ '@type': 'Person',
16
+ name: post.author,
17
+ };
18
+ }
19
+
20
+ if (post.image) {
21
+ article.image = post.image;
22
+ }
23
+
24
+ if (post.tags && post.tags.length > 0) {
25
+ article.keywords = post.tags.join(', ');
26
+ }
27
+
28
+ return article;
29
+ }
@@ -0,0 +1,29 @@
1
+ export interface SiteSEOConfig {
2
+ siteName?: string;
3
+ baseUrl?: string;
4
+ defaultOgImage?: string;
5
+ hreflang?: boolean;
6
+ hreflangLanguages?: string[];
7
+ hreflangXDefault?: string;
8
+ }
9
+
10
+ export interface SEOConfig {
11
+ title: string;
12
+ description: string;
13
+ ogImage?: string;
14
+ canonical?: string;
15
+ type?: 'website' | 'article';
16
+ publishedTime?: string;
17
+ author?: string;
18
+ jsonld?: object[];
19
+ }
20
+
21
+ export interface BlogPostSchema {
22
+ title: string;
23
+ excerpt: string;
24
+ date: string;
25
+ author?: string;
26
+ image?: string;
27
+ slug: string;
28
+ tags?: string[];
29
+ }
@@ -0,0 +1,17 @@
1
+ import { useEffect } from 'react';
2
+
3
+ export function usePageMeta(description: string) {
4
+ useEffect(() => {
5
+ let meta = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
6
+ const previous = meta?.content;
7
+ if (!meta) {
8
+ meta = document.createElement('meta');
9
+ meta.name = 'description';
10
+ document.head.appendChild(meta);
11
+ }
12
+ meta.content = description;
13
+ return () => {
14
+ if (meta && previous !== undefined) meta.content = previous;
15
+ };
16
+ }, [description]);
17
+ }
@@ -0,0 +1,14 @@
1
+ import { useEffect } from 'react';
2
+
3
+ const DEFAULT_SITE_NAME = 'Autional';
4
+
5
+ export function usePageTitle(title: string, siteName?: string) {
6
+ const name = siteName || DEFAULT_SITE_NAME;
7
+ useEffect(() => {
8
+ const previous = document.title;
9
+ document.title = title ? `${title} — ${name}` : name;
10
+ return () => {
11
+ document.title = previous;
12
+ };
13
+ }, [title, name]);
14
+ }
@@ -0,0 +1,80 @@
1
+ import { useEffect } from 'react';
2
+ import type { SEOConfig, SiteSEOConfig } from './types';
3
+ import { upsertMeta, upsertCanonical, upsertLink, injectJSONLD } from './dom-utils';
4
+ import { SITE_BASE_URL, DEFAULT_OG_IMAGE } from '../config/constants';
5
+
6
+ const DEFAULTS: Required<SiteSEOConfig> = {
7
+ siteName: 'Autional',
8
+ baseUrl: SITE_BASE_URL,
9
+ defaultOgImage: DEFAULT_OG_IMAGE,
10
+ hreflang: false,
11
+ hreflangLanguages: ['zh', 'en'],
12
+ hreflangXDefault: `${SITE_BASE_URL}/zh/`,
13
+ };
14
+
15
+ export function useSEO(config: SEOConfig, siteConfig?: SiteSEOConfig) {
16
+ const s = { ...DEFAULTS, ...siteConfig };
17
+ const { title, description, ogImage, canonical, type, publishedTime, author, jsonld } = {
18
+ ogImage: s.defaultOgImage,
19
+ type: 'website' as const,
20
+ ...config,
21
+ };
22
+
23
+ useEffect(() => {
24
+ const pageTitle = title ? `${title} — ${s.siteName}` : s.siteName;
25
+ const prevTitle = document.title;
26
+ document.title = pageTitle;
27
+
28
+ const cleanups: (() => void)[] = [];
29
+
30
+ if (s.hreflang) {
31
+ try {
32
+ const currentPath = window.location.pathname;
33
+ const pathMatch = currentPath.match(/^\/(zh|en)(\/.*)?$/);
34
+ if (pathMatch) {
35
+ const pagePath = pathMatch[2] || '/';
36
+ for (const lang of s.hreflangLanguages) {
37
+ const url = `${s.baseUrl}/${lang}${pagePath === '/' ? '' : pagePath}`;
38
+ cleanups.push(upsertLink('alternate', url, { hreflang: lang }));
39
+ }
40
+ cleanups.push(upsertLink('alternate', s.hreflangXDefault, { hreflang: 'x-default' }));
41
+ }
42
+ } catch {
43
+ /* SSR-safe */
44
+ }
45
+ }
46
+
47
+ cleanups.push(upsertMeta('description', description));
48
+ cleanups.push(upsertMeta('og:title', pageTitle, 'property'));
49
+ cleanups.push(upsertMeta('og:description', description, 'property'));
50
+ cleanups.push(upsertMeta('og:image', ogImage, 'property'));
51
+ cleanups.push(upsertMeta('og:type', type, 'property'));
52
+ cleanups.push(upsertMeta('og:url', canonical || window.location.href, 'property'));
53
+ cleanups.push(upsertMeta('twitter:card', 'summary_large_image'));
54
+ cleanups.push(upsertMeta('twitter:title', pageTitle));
55
+ cleanups.push(upsertMeta('twitter:description', description));
56
+ cleanups.push(upsertMeta('twitter:image', ogImage));
57
+
58
+ if (type === 'article' && publishedTime) {
59
+ cleanups.push(upsertMeta('article:published_time', publishedTime, 'property'));
60
+ }
61
+ if (type === 'article' && author) {
62
+ cleanups.push(upsertMeta('article:author', author, 'property'));
63
+ }
64
+
65
+ if (canonical) {
66
+ cleanups.push(upsertCanonical(canonical));
67
+ }
68
+
69
+ let cleanupJSONLD: (() => void) | undefined;
70
+ if (jsonld && jsonld.length > 0) {
71
+ cleanupJSONLD = injectJSONLD(jsonld);
72
+ }
73
+
74
+ return () => {
75
+ document.title = prevTitle;
76
+ cleanups.forEach((fn) => fn());
77
+ cleanupJSONLD?.();
78
+ };
79
+ }, [title, description, ogImage, canonical, type, publishedTime, author, JSON.stringify(jsonld)]);
80
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Autional 共享 TypeScript 类型
3
+ * 所有前端应用共用
4
+ */
5
+
6
+ // ============ 用户 ============
7
+
8
+ export interface User {
9
+ id: string;
10
+ username: string;
11
+ email: string;
12
+ status: 'active' | 'inactive' | 'locked' | 'pending';
13
+ createdAt?: string;
14
+ updatedAt?: string;
15
+ lastLoginAt?: string;
16
+ avatarUrl?: string;
17
+ /**
18
+ * 展示名(OIDC userinfo 的 nickname→name,登录时合入)。
19
+ * 消费方展示优先级:displayName → username → email。
20
+ */
21
+ displayName?: string;
22
+ phone?: string;
23
+ roles?: RoleSummary[];
24
+ department?: DepartmentSummary;
25
+ mfaEnabled?: boolean;
26
+ /** 后端 /auth/me 返回的扩展字段(可选,向后兼容) */
27
+ metadata?: Record<string, unknown>;
28
+ tenant_type?: string;
29
+ tenant_id?: string;
30
+ }
31
+
32
+ export interface RoleSummary {
33
+ id: string;
34
+ name: string;
35
+ }
36
+
37
+ export interface DepartmentSummary {
38
+ id: string;
39
+ name: string;
40
+ }
41
+
42
+ // ============ 认证 ============
43
+
44
+ export interface LoginRequest {
45
+ identity: string;
46
+ password: string;
47
+ }
48
+
49
+ export interface LoginResponse {
50
+ accessToken: string;
51
+ refreshToken: string;
52
+ expiresIn?: number;
53
+ tokenType?: string;
54
+ user: User;
55
+ }
56
+
57
+ export interface RegisterRequest {
58
+ username: string;
59
+ email: string;
60
+ password: string;
61
+ }
62
+
63
+ // ============ 租户 ============
64
+
65
+ export interface Tenant {
66
+ id: string;
67
+ name: string;
68
+ domain?: string;
69
+ status: 'active' | 'suspended' | 'pending';
70
+ createdAt?: string;
71
+ }
72
+
73
+ export interface TenantMember {
74
+ userId: string;
75
+ username: string;
76
+ email: string;
77
+ role: 'owner' | 'admin' | 'member';
78
+ status: string;
79
+ joinedAt?: string;
80
+ }
81
+
82
+ // ============ RBAC ============
83
+
84
+ export interface Role {
85
+ id: string;
86
+ code: string;
87
+ name: string;
88
+ description: string;
89
+ }
90
+
91
+ export interface Permission {
92
+ id: string;
93
+ code: string;
94
+ name: string;
95
+ description: string;
96
+ category: string;
97
+ }
98
+
99
+ // ============ 应用 ============
100
+
101
+ export interface Application {
102
+ id: string;
103
+ name: string;
104
+ type: 'oidc' | 'saml' | 'custom';
105
+ clientId: string;
106
+ status: string;
107
+ redirectUris: string[];
108
+ createdAt?: string;
109
+ }
110
+
111
+ // ============ 审计 ============
112
+
113
+ export interface AuditLog {
114
+ id: string;
115
+ createdAt: string;
116
+ actorId: string;
117
+ actorName: string;
118
+ action: string;
119
+ resourceType: string;
120
+ resourceId: string;
121
+ description: string;
122
+ result: 'success' | 'failure';
123
+ ipAddress: string;
124
+ }
125
+
126
+ // ============ 会话 ============
127
+
128
+ export interface Session {
129
+ id: string;
130
+ userId: string;
131
+ username: string;
132
+ ipAddress: string;
133
+ device: string;
134
+ browser: string;
135
+ location: string;
136
+ riskScore: number;
137
+ createdAt: string;
138
+ lastActiveAt: string;
139
+ }
140
+
141
+ // ============ 部门 ============
142
+
143
+ export interface Department {
144
+ id: string;
145
+ name: string;
146
+ parentId?: string;
147
+ memberCount: number;
148
+ }
149
+
150
+ // ============ 列表响应 ============
151
+
152
+ export interface ListResponse<T> {
153
+ items: T[];
154
+ total: number;
155
+ pagination?: {
156
+ page: number;
157
+ pageSize: number;
158
+ totalPages: number;
159
+ };
160
+ }
161
+
162
+ // ============ 分页请求 ============
163
+
164
+ export interface PageRequest {
165
+ page?: number;
166
+ pageSize?: number;
167
+ }
168
+
169
+ // ============ API 错误 ============
170
+
171
+ export interface ApiError {
172
+ code: string;
173
+ message: string;
174
+ }
175
+
176
+ // ============ 后端原始响应 ============
177
+
178
+ export interface ApiResponse<T> {
179
+ code: number;
180
+ message: string;
181
+ data?: T;
182
+ items?: T[];
183
+ total?: number;
184
+ pagination?: {
185
+ page: number;
186
+ pageSize: number;
187
+ totalPages: number;
188
+ };
189
+ timestamp?: number;
190
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { snakeCaseKeys, camelCaseKeys } from '../case';
3
+
4
+ // U385 回归锁(AC-AUTH-39/U385):snakeCaseKeys 必须 acronym-aware。
5
+ // 此前朴素正则(每个大写字母前插 `_`)把 WebAuthn 请求体的 camel 键 mangling:
6
+ // clientDataJSON → client_data_j_s_o_n(服务端期望 client_data_json,go-webauthn 校验恒败)
7
+ // 真实受害链:passkey begin/complete 请求经 apiClient 拦截器后键名错位 → 400/校验失败。
8
+ describe('snakeCaseKeys acronym-aware(U385)', () => {
9
+ it('WebAuthn camel 键族 → 正确 snake(旧实现 mangling 的键)', () => {
10
+ expect(
11
+ snakeCaseKeys({
12
+ clientDataJSON: 'cdj',
13
+ attestationObject: 'ao',
14
+ rawId: 'rid',
15
+ userHandle: 'uh',
16
+ transports: ['usb'],
17
+ }),
18
+ ).toEqual({
19
+ client_data_json: 'cdj',
20
+ attestation_object: 'ao',
21
+ raw_id: 'rid',
22
+ user_handle: 'uh',
23
+ transports: ['usb'],
24
+ });
25
+ });
26
+
27
+ it('尾部缩写:userID → user_id、testID → test_id(旧实现 user_i_d/test_i_d)', () => {
28
+ expect(snakeCaseKeys({ userID: 'u', testID: 't' })).toEqual({ user_id: 'u', test_id: 't' });
29
+ });
30
+
31
+ it('前导缩写:URLValue → url_value、APIVersion → api_version、HTMLParser → html_parser', () => {
32
+ expect(
33
+ snakeCaseKeys({ URLValue: 'v', APIVersion: 'v1', HTMLParser: true }),
34
+ ).toEqual({ url_value: 'v', api_version: 'v1', html_parser: true });
35
+ });
36
+
37
+ it('数字边界:sha256Value → sha256_value、base64URL → base64_url', () => {
38
+ expect(snakeCaseKeys({ sha256Value: 'v', base64URL: 'b' })).toEqual({
39
+ sha256_value: 'v',
40
+ base64_url: 'b',
41
+ });
42
+ });
43
+
44
+ it('常规 camel 不回归:pageSize/currentPage(contract G1 同口径)', () => {
45
+ expect(snakeCaseKeys({ pageSize: 20, currentPage: 2 })).toEqual({
46
+ page_size: 20,
47
+ current_page: 2,
48
+ });
49
+ });
50
+
51
+ it('已是 snake 的字面量键原样保留(含下划线/全小写)', () => {
52
+ expect(snakeCaseKeys({ client_data_json: 'v', plain: 1 })).toEqual({
53
+ client_data_json: 'v',
54
+ plain: 1,
55
+ });
56
+ });
57
+
58
+ it('深递归:嵌套对象/数组同样走 acronym-aware 转换', () => {
59
+ expect(snakeCaseKeys({ webAuthn: { clientDataJSON: 'x', list: [{ rawID: 'r' }] } })).toEqual({
60
+ web_authn: { client_data_json: 'x', list: [{ raw_id: 'r' }] },
61
+ });
62
+ });
63
+ });
64
+
65
+ // U407 wire 约定锁定:数字贴词尾(`_<数字>` 形态对 lodash camel 折叠不可逆,wire 禁止使用)。
66
+ // 原 wire 键 live_stream_blocked_under_16 与 under16 在响应方向折叠为同名 liveStreamBlockedUnder16
67
+ // ⇒ 请求方向无法还原下划线位置(line1/under_16 同形歧义)。平台侧已把该字段归一为
68
+ // live_stream_blocked_under16(service-tenant U407),本锁固定双向往返无损 + 反例(下划线-数字形态不可逆)。
69
+ describe('snakeCaseKeys ↔ camelCaseKeys 往返(U407 wire 键约定:数字贴词)', () => {
70
+ it('live_stream_blocked_under16 双向无损(平台 minors 字段现行 wire 形态)', () => {
71
+ expect(snakeCaseKeys({ liveStreamBlockedUnder16: true })).toEqual({
72
+ live_stream_blocked_under16: true,
73
+ });
74
+ expect(camelCaseKeys({ live_stream_blocked_under16: true })).toEqual({
75
+ liveStreamBlockedUnder16: true,
76
+ });
77
+ });
78
+
79
+ it('反例说明:下划线-数字形态(under_16)不可由 camel 还原——故 wire 禁用该形态', () => {
80
+ // camelCaseKeys 折叠后与 under16 同名;snakeCaseKeys 只能还原出 under16,不可能知道下划线位置。
81
+ expect(camelCaseKeys({ under_16: true })).toEqual({ under16: true });
82
+ expect(snakeCaseKeys({ under16: true })).toEqual({ under16: true });
83
+ });
84
+ });
85
+
86
+ // camelCaseKeys 回归边界:lodash camelCase 对缩写的行为(响应方向契约,保持现状即可)
87
+ describe('camelCaseKeys 响应方向(现状锁定)', () => {
88
+ it('snake → camel;缩写键由 lodash 归一为小写驼峰(client_data_json → clientDataJson)', () => {
89
+ expect(camelCaseKeys({ client_data_json: 'v', page_size: 10 })).toEqual({
90
+ clientDataJson: 'v',
91
+ pageSize: 10,
92
+ });
93
+ });
94
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractApiError, extractApiErrorMessage } from '../error';
3
+
4
+ // UP-17-B 回归锁(AC-401):extractApiError message 键链。
5
+ // 后端 Problem 契约(service-core/base/dto/problem.go)为 title 必填 / detail / code / i18n_key,无 message;
6
+ // 只认 message 会把 Problem 类错误整类退化为 defaultMsg。
7
+ describe('extractApiError message 键链(UP-17-B / AC-401)', () => {
8
+ it('title 优先:Problem 契约(title/detail/code)→ 取 title', () => {
9
+ const err = {
10
+ response: { data: { code: 403, title: '平台级访问受限', detail: '需要平台角色' } },
11
+ };
12
+ const out = extractApiError(err, 'fallback');
13
+ expect(out.message).toBe('平台级访问受限');
14
+ expect(out.code).toBe(403);
15
+ });
16
+
17
+ it('detail 兜底:无 message/title 时取 detail', () => {
18
+ const err = { response: { data: { detail: '令牌已过期' } } };
19
+ expect(extractApiError(err, 'fallback').message).toBe('令牌已过期');
20
+ });
21
+
22
+ it('皆无回落 err.message,再皆无回落 defaultMsg', () => {
23
+ expect(extractApiError(new Error('network boom'), 'fallback').message).toBe('network boom');
24
+ expect(extractApiError({}, 'fallback').message).toBe('fallback');
25
+ expect(extractApiError(undefined, 'fallback')).toEqual({ code: 'UNKNOWN', message: 'fallback' });
26
+ });
27
+
28
+ it('业务信封(code+message)不回归:message 仍优先于 title', () => {
29
+ const err = {
30
+ response: { data: { code: 61040010, message: 'TOTP already enabled', title: 'Conflict' } },
31
+ };
32
+ expect(extractApiError(err, 'fallback')).toEqual({
33
+ code: 61040010,
34
+ message: 'TOTP already enabled',
35
+ });
36
+ expect(extractApiErrorMessage(err, 'fallback')).toBe('TOTP already enabled');
37
+ });
38
+ });
39
+
40
+ // AUTH-46 回归锁:i18n_key 透出(消费方按 key 映射本地化文案,不再暴露英文裸句)。
41
+ describe('extractApiError i18n_key 透出(AUTH-46)', () => {
42
+ it('Problem 体带 i18n_key → i18nKey 字段透出(snake 形)', () => {
43
+ const err = {
44
+ response: {
45
+ data: {
46
+ code: 400,
47
+ title: 'Invalid Parameter',
48
+ detail: 'state not found',
49
+ i18n_key: 'error.oauth_state_missing',
50
+ },
51
+ },
52
+ };
53
+ const out = extractApiError(err, 'fallback');
54
+ expect(out.i18nKey).toBe('error.oauth_state_missing');
55
+ // message 键链不回归(title 优先于 detail)
56
+ expect(out.message).toBe('Invalid Parameter');
57
+ });
58
+
59
+ it('响应拦截器深 camel 后(i18nKey)同样识别', () => {
60
+ const err = { response: { data: { code: 400, i18nKey: 'error.sso_provider_not_configured' } } };
61
+ expect(extractApiError(err, 'fallback').i18nKey).toBe('error.sso_provider_not_configured');
62
+ });
63
+
64
+ it('无 i18n_key → undefined(向后兼容,不改变既有形状)', () => {
65
+ expect(extractApiError(new Error('boom'), 'fallback').i18nKey).toBeUndefined();
66
+ expect(extractApiError({}, 'fallback')).toEqual({
67
+ code: 'UNKNOWN',
68
+ message: 'fallback',
69
+ i18nKey: undefined,
70
+ });
71
+ });
72
+ });
@@ -0,0 +1,15 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ // Mock from test's relative path
4
+ vi.mock('../../generated/api', () => ({
5
+ PublicKeyExchange: vi.fn(() => Promise.resolve({ server_pub_key: 'x', key_exchange_id: 'y' })),
6
+ }));
7
+
8
+ import { PublicKeyExchange as pk } from '../../generated/api';
9
+
10
+ describe('mock test', () => {
11
+ it('should return mock value', async () => {
12
+ const result = await pk();
13
+ expect(result).toEqual({ server_pub_key: 'x', key_exchange_id: 'y' });
14
+ });
15
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { toPageParams, fromPageResult } from '../page';
3
+
4
+ describe('toPageParams(AC-AB1-29 请求侧)', () => {
5
+ it('camel 入参 → 输出键集恰为 page/page_size(无 camel 残留)', () => {
6
+ const out = toPageParams({ page: 2, pageSize: 20 });
7
+ expect(Object.keys(out).sort()).toEqual(['page', 'page_size']);
8
+ expect(out).toEqual({ page: 2, page_size: 20 });
9
+ expect('pageSize' in out).toBe(false);
10
+ });
11
+
12
+ it('pageSize 缺省不输出键', () => {
13
+ expect(toPageParams({ page: 1 })).toEqual({ page: 1 });
14
+ expect(Object.keys(toPageParams())).toEqual([]);
15
+ expect(Object.keys(toPageParams({ pageSize: 5 }))).toEqual(['page_size']);
16
+ });
17
+ });
18
+
19
+ describe('fromPageResult(AC-AB1-29 响应侧三形态归一)', () => {
20
+ it('扁平 {items,total} → items/total/page/page_size', () => {
21
+ const out = fromPageResult<{ id: string }>({
22
+ items: [{ id: 'a' }, { id: 'b' }],
23
+ total: 42,
24
+ page: 2,
25
+ page_size: 20,
26
+ });
27
+ expect(out.items).toHaveLength(2);
28
+ expect(out.total).toBe(42);
29
+ expect(out.page).toBe(2);
30
+ expect(out.page_size).toBe(20);
31
+ });
32
+
33
+ it('信封 {data:{items,total}} → 归一成功(total 取 data.total)', () => {
34
+ const out = fromPageResult<{ id: string }>({
35
+ data: { items: [{ id: 'x' }], total: 7, page: 3, page_size: 10 },
36
+ });
37
+ expect(out.items).toEqual([{ id: 'x' }]);
38
+ expect(out.total).toBe(7);
39
+ expect(out.page).toBe(3);
40
+ expect(out.page_size).toBe(10);
41
+ });
42
+
43
+ it('裸数组 → items 为数组本体,total 取长度', () => {
44
+ const out = fromPageResult<string>(['a', 'b', 'c']);
45
+ expect(out.items).toEqual(['a', 'b', 'c']);
46
+ expect(out.total).toBe(3);
47
+ });
48
+
49
+ it('空输入 → 空列表零值,不抛错', () => {
50
+ const out = fromPageResult(null);
51
+ expect(out.items).toEqual([]);
52
+ expect(out.total).toBe(0);
53
+ });
54
+ });