@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.
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,120 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ classifyQueryState,
4
+ getHttpStatus,
5
+ isEmptyData,
6
+ isForbiddenError,
7
+ isRetryableError,
8
+ } from '../query-state';
9
+
10
+ // RC-B4-01(AC-B4-W0-01-2):优先级矩阵硬序 forbidden > error > loading > empty > ready。
11
+ // 关键不变式:error 存在时绝不回落 empty/ready;loading 且有旧数据 → ready。
12
+ describe('classifyQueryState 优先级矩阵(AC-B4-W0-01-2)', () => {
13
+ const forbidden = () => ({ response: { status: 403 } });
14
+ const serverError = () => ({ response: { status: 500 } });
15
+ const networkError = () => new Error('Network Error');
16
+
17
+ it('forbidden 最高优先:403 时即使有 data/loading 仍判 forbidden', () => {
18
+ expect(classifyQueryState({ error: forbidden(), data: [1], isLoading: false })).toBe('forbidden');
19
+ expect(classifyQueryState({ error: forbidden(), isLoading: true })).toBe('forbidden');
20
+ });
21
+
22
+ it('error 存在时绝不回落 empty/ready(假 0 / 伪空态根因锁)', () => {
23
+ // error + 旧数据 → error(不因 data 有值回落 ready)
24
+ expect(classifyQueryState({ error: serverError(), data: [1] })).toBe('error');
25
+ // error + 空数组 → error(不回落 empty 伪装「暂无数据」)
26
+ expect(classifyQueryState({ error: networkError(), data: [] })).toBe('error');
27
+ // error + data undefined → error
28
+ expect(classifyQueryState({ error: networkError(), isLoading: false })).toBe('error');
29
+ });
30
+
31
+ it('error 优先于 loading', () => {
32
+ expect(classifyQueryState({ error: serverError(), isLoading: true })).toBe('error');
33
+ });
34
+
35
+ it('loading 且无旧数据 → loading', () => {
36
+ expect(classifyQueryState({ isLoading: true })).toBe('loading');
37
+ expect(classifyQueryState({ isLoading: true, data: [] })).toBe('loading');
38
+ });
39
+
40
+ it('loading 且有旧数据 → ready(stale 显示优先,不闪加载态)', () => {
41
+ expect(classifyQueryState({ isLoading: true, data: [1, 2] })).toBe('ready');
42
+ });
43
+
44
+ it('非 loading:空数据 → empty(null/undefined/[])', () => {
45
+ expect(classifyQueryState({ data: [] })).toBe('empty');
46
+ expect(classifyQueryState({ data: null })).toBe('empty');
47
+ expect(classifyQueryState({})).toBe('empty');
48
+ expect(classifyQueryState({ isLoading: false })).toBe('empty');
49
+ });
50
+
51
+ it('有数据 → ready', () => {
52
+ expect(classifyQueryState({ data: [1] })).toBe('ready');
53
+ expect(classifyQueryState({ data: { total: 0 } })).toBe('ready');
54
+ });
55
+
56
+ it('无 error(null/undefined)不触发 error 分支', () => {
57
+ expect(classifyQueryState({ error: null, data: [1] })).toBe('ready');
58
+ expect(classifyQueryState({ error: undefined, data: [] })).toBe('empty');
59
+ });
60
+ });
61
+
62
+ // AC-B4-W0-01-3:isRetryable 矩阵(无 status/5xx/408/429 → true;4xx 业务错误 → false)。
63
+ describe('isRetryableError 矩阵(AC-B4-W0-01-3)', () => {
64
+ it('无 status(网络/超时/未知)→ true', () => {
65
+ expect(isRetryableError(new Error('Network Error'))).toBe(true);
66
+ expect(isRetryableError({})).toBe(true);
67
+ expect(isRetryableError(undefined)).toBe(true);
68
+ });
69
+
70
+ it('5xx / 408 / 429 → true', () => {
71
+ expect(isRetryableError({ response: { status: 500 } })).toBe(true);
72
+ expect(isRetryableError({ response: { status: 502 } })).toBe(true);
73
+ expect(isRetryableError({ response: { status: 503 } })).toBe(true);
74
+ expect(isRetryableError({ response: { status: 408 } })).toBe(true);
75
+ expect(isRetryableError({ response: { status: 429 } })).toBe(true);
76
+ });
77
+
78
+ it('4xx 业务错误 → false(重试必败,不得给必败重试按钮)', () => {
79
+ for (const status of [400, 401, 403, 404, 409, 422]) {
80
+ expect(isRetryableError({ response: { status } })).toBe(false);
81
+ }
82
+ });
83
+ });
84
+
85
+ // getHttpStatus 形状兼容(axios 主形状 + fetch/自造形状)。
86
+ describe('getHttpStatus 形状兼容', () => {
87
+ it('axios:response.status 优先', () => {
88
+ expect(getHttpStatus({ response: { status: 403 }, status: 500 })).toBe(403);
89
+ });
90
+
91
+ it('直挂 status / statusCode(fetch 及自造错误形状)', () => {
92
+ expect(getHttpStatus({ status: 404 })).toBe(404);
93
+ expect(getHttpStatus({ statusCode: 500 })).toBe(500);
94
+ });
95
+
96
+ it('非对象 / 无数字状态 → undefined', () => {
97
+ expect(getHttpStatus(undefined)).toBeUndefined();
98
+ expect(getHttpStatus(null)).toBeUndefined();
99
+ expect(getHttpStatus('boom')).toBeUndefined();
100
+ expect(getHttpStatus({ response: { status: '403' } })).toBeUndefined();
101
+ });
102
+ });
103
+
104
+ describe('isForbiddenError / isEmptyData 边界', () => {
105
+ it('isForbiddenError 只认 403(401 不在本分流)', () => {
106
+ expect(isForbiddenError({ response: { status: 403 } })).toBe(true);
107
+ expect(isForbiddenError({ response: { status: 401 } })).toBe(false);
108
+ expect(isForbiddenError({ response: { status: 500 } })).toBe(false);
109
+ expect(isForbiddenError(undefined)).toBe(false);
110
+ });
111
+
112
+ it('isEmptyData:null/undefined/[] → true;有元素/对象 → false', () => {
113
+ expect(isEmptyData(null)).toBe(true);
114
+ expect(isEmptyData(undefined)).toBe(true);
115
+ expect(isEmptyData([])).toBe(true);
116
+ expect(isEmptyData([1])).toBe(false);
117
+ expect(isEmptyData({})).toBe(false);
118
+ expect(isEmptyData(0)).toBe(false);
119
+ });
120
+ });
@@ -0,0 +1,15 @@
1
+ // 返回值必须显式写成 Uint8Array<ArrayBuffer>:TS 5.7 起 TypedArray 带上了 buffer 的类型参数,
2
+ // 裸写 Uint8Array 等于 Uint8Array<ArrayBufferLike>,而 PushManager.subscribe 要的是 BufferSource
3
+ // (= ArrayBufferView<ArrayBuffer>)—— 于是 @autional/react 那边整包类型检查红着,
4
+ // 而 shared 自己的项目里看不出来(两边 tsconfig 的解析路径不同)。实测踩到,见计划 L1。
5
+ export function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
6
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
7
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
8
+ const rawData = window.atob(base64);
9
+ const bytes = Uint8Array.from(rawData.split('').map((c) => c.charCodeAt(0)));
10
+ // from() 的结果在类型上是 Uint8Array<ArrayBuffer>,这里显式复制到独立 ArrayBuffer
11
+ // 只是为了把「缓冲区归属」写死,避免将来换实现时又退回 ArrayBufferLike。
12
+ const out = new Uint8Array(new ArrayBuffer(bytes.length));
13
+ out.set(bytes);
14
+ return out;
15
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * 字段名大小写转换工具
3
+ * 后端 Go 返回 PascalCase,前端统一使用 camelCase
4
+ */
5
+
6
+ import { camelCase, isPlainObject, isArray } from 'lodash-es';
7
+
8
+ /**
9
+ * 递归将对象的所有键从 PascalCase/snake_case 转换为 camelCase
10
+ */
11
+ export function camelCaseKeys<T>(obj: T): T {
12
+ if (isArray(obj)) {
13
+ return obj.map(camelCaseKeys) as unknown as T;
14
+ }
15
+ if (!isPlainObject(obj) || obj === null) {
16
+ return obj;
17
+ }
18
+
19
+ const result: Record<string, unknown> = {};
20
+ for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
21
+ result[camelCase(key)] = camelCaseKeys(value);
22
+ }
23
+ return result as T;
24
+ }
25
+
26
+ /**
27
+ * 单个键 camelCase → snake_case(acronym-aware)。
28
+ * U385:朴素正则(每个大写字母前插 `_`)会把缩写拆散——clientDataJSON →
29
+ * client_data_j_s_o_n、userID → user_i_d——WebAuthn 请求体(camel 书面键)经拦截器后
30
+ * 键名 mangling,服务端 go-webauthn 判为缺字段、校验恒败。两步修正:
31
+ * ① 小写/数字 → 大写 边界插 `_`(aB → a_B);
32
+ * ② 连续大写后紧跟「大写+小写」时在缩写尾部插 `_`(JSONValue → JSON_Value)。
33
+ * 已是 snake 的字面量键(含下划线/无大写)两步均不命中,原样保留。
34
+ */
35
+ function toSnakeKey(key: string): string {
36
+ return key
37
+ .replace(/([a-z\d])([A-Z])/g, '$1_$2')
38
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
39
+ .toLowerCase();
40
+ }
41
+
42
+ /**
43
+ * 递归将对象的所有键从 camelCase 转换为 snake_case
44
+ * 用于发送请求到后端
45
+ */
46
+ export function snakeCaseKeys<T>(obj: T): T {
47
+ if (isArray(obj)) {
48
+ return obj.map(snakeCaseKeys) as unknown as T;
49
+ }
50
+ if (!isPlainObject(obj) || obj === null) {
51
+ return obj;
52
+ }
53
+
54
+ const result: Record<string, unknown> = {};
55
+ for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
56
+ result[toSnakeKey(key)] = snakeCaseKeys(value);
57
+ }
58
+ return result as T;
59
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared error handling utilities
3
+ * Framework-agnostic: works with any UI notification system
4
+ */
5
+
6
+ export interface ExtractedApiError {
7
+ code: number | string;
8
+ message: string;
9
+ /** AUTH-46:后端 Problem 契约的 i18n_key(如 error.oauth_state_invalid)。消费方可据此
10
+ * 映射本地化文案;缺省时 undefined(message 键链不变,向后兼容)。 */
11
+ i18nKey?: string;
12
+ }
13
+
14
+ export function extractApiErrorMessage(err: unknown, defaultMsg: string): string {
15
+ return extractApiError(err, defaultMsg).message;
16
+ }
17
+
18
+ export function extractApiError(err: unknown, defaultMsg: string): ExtractedApiError {
19
+ // UP-17-B:后端 Problem 契约(service-core/base/dto/problem.go)字段为 title(必填)/ detail / code / i18n_key,
20
+ // **没有 message** —— 只认 message 会把整类后端错误退化成 defaultMsg。键链:message → title → detail → err.message。
21
+ // 继续用 `||`(而非 `??`):空串语义按「缺省」处理,与既有实现一致。
22
+ // AUTH-46:附加透出 i18n_key(响应拦截器深 camel 后为 i18nKey,两者都认)。
23
+ const data = (err as any)?.response?.data;
24
+ return {
25
+ code: data?.code || 'UNKNOWN',
26
+ message: data?.message || data?.title || data?.detail || (err as any)?.message || defaultMsg,
27
+ i18nKey: data?.i18n_key || data?.i18nKey || undefined,
28
+ };
29
+ }
30
+
31
+ export function createHandleApiError(notify: (msg: string) => void) {
32
+ return (err: unknown, defaultMsg: string): void => {
33
+ notify(extractApiErrorMessage(err, defaultMsg));
34
+ };
35
+ }
@@ -0,0 +1,63 @@
1
+ let localeGetter: (() => string) | null = null;
2
+
3
+ export function setLocaleGetter(fn: () => string) {
4
+ localeGetter = fn;
5
+ }
6
+
7
+ function getLocale(): string {
8
+ if (localeGetter) return localeGetter();
9
+ return 'zh-CN';
10
+ }
11
+
12
+ export function formatTime(iso: string | undefined | null, locale?: string): string {
13
+ if (!iso) return '--';
14
+ try {
15
+ const d = new Date(iso);
16
+ if (isNaN(d.getTime())) return iso;
17
+ return d.toLocaleString(locale || getLocale(), {
18
+ year: 'numeric',
19
+ month: '2-digit',
20
+ day: '2-digit',
21
+ hour: '2-digit',
22
+ minute: '2-digit',
23
+ });
24
+ } catch {
25
+ return iso;
26
+ }
27
+ }
28
+
29
+ export function formatDate(iso: string | undefined | null, locale?: string): string {
30
+ if (!iso) return '--';
31
+ try {
32
+ const d = new Date(iso);
33
+ if (isNaN(d.getTime())) return iso;
34
+ return d.toLocaleDateString(locale || getLocale(), {
35
+ year: 'numeric',
36
+ month: '2-digit',
37
+ day: '2-digit',
38
+ });
39
+ } catch {
40
+ return iso;
41
+ }
42
+ }
43
+
44
+ export function formatRelativeTime(iso: string | undefined | null, locale?: string): string {
45
+ if (!iso) return '--';
46
+ try {
47
+ const d = new Date(iso);
48
+ if (isNaN(d.getTime())) return iso;
49
+ const now = Date.now();
50
+ const diff = now - d.getTime();
51
+ const mins = Math.floor(diff / 60000);
52
+ const hours = Math.floor(diff / 3600000);
53
+ const days = Math.floor(diff / 86400000);
54
+
55
+ if (mins < 1) return 'just now';
56
+ if (mins < 60) return `${mins}m ago`;
57
+ if (hours < 24) return `${hours}h ago`;
58
+ if (days < 7) return `${days}d ago`;
59
+ return formatDate(iso, locale);
60
+ } catch {
61
+ return iso;
62
+ }
63
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 分页/形状单点 helper(H008 收敛口径 · ADR-AB1-04)。
3
+ *
4
+ * 契约(与 `api/client.ts` 拦截器配对):
5
+ * - 请求侧:页面传 camel 入参,`toPageParams` 输出 **snake**(`page` / `page_size`);
6
+ * 拦截器再做一次深转换兜底,本函数保证键名口径单点。
7
+ * - 响应侧:拦截器已把 snake→camel 且解包信封;`fromPageResult` 是列表+分页的
8
+ * 唯一归一入口(内部复用 `extractList` / `extractPagination`),页面不得再写
9
+ * `res?.data?.items ?? ...` 式手搓兼容。
10
+ */
11
+ import { extractList, extractPagination } from './response';
12
+
13
+ export interface PageParams {
14
+ page?: number;
15
+ page_size?: number;
16
+ }
17
+
18
+ export interface PageResult<T = Record<string, any>> {
19
+ items: T[];
20
+ total: number;
21
+ page: number;
22
+ page_size: number;
23
+ }
24
+
25
+ /** camel 分页入参 → 后端 snake 参数(pageSize 缺省不输出键;输出键集恰为 page/page_size)。 */
26
+ export function toPageParams(input: { page?: number; pageSize?: number } = {}): PageParams {
27
+ const out: PageParams = {};
28
+ if (typeof input.page === 'number') out.page = input.page;
29
+ if (typeof input.pageSize === 'number') out.page_size = input.pageSize;
30
+ return out;
31
+ }
32
+
33
+ /**
34
+ * 响应归一:兼容三种输入——
35
+ * ① 扁平 `{ items, total, ... }`;② 信封 `{ data: { items, total } }`(拦截器未替换引用时的直读);③ 裸数组。
36
+ * 裸数组的 total 取数组长度(唯一可知值)。
37
+ */
38
+ export function fromPageResult<T = Record<string, any>>(resp: unknown): PageResult<T> {
39
+ const items = extractList<T>(resp);
40
+ const pagination = extractPagination(resp ?? {});
41
+ const r = (resp ?? {}) as Record<string, any>;
42
+ const dataObj = r.data as Record<string, any> | undefined;
43
+ const pickNum = (...vals: unknown[]): number | undefined => {
44
+ for (const v of vals) if (typeof v === 'number') return v;
45
+ return undefined;
46
+ };
47
+
48
+ return {
49
+ items,
50
+ total:
51
+ pickNum(r.total, dataObj?.total) ?? (Array.isArray(resp) ? items.length : pagination.total),
52
+ page: pickNum(r.page, dataObj?.page) ?? pagination.page,
53
+ page_size:
54
+ pickNum(r.page_size, r.pageSize, dataObj?.page_size, dataObj?.pageSize) ??
55
+ pagination.pageSize,
56
+ };
57
+ }
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createHash } from 'crypto';
3
+
4
+ // SAME specifier string as source file's import ('../generated/api')
5
+ vi.mock('../generated/api', () => ({
6
+ PublicKeyExchange: vi.fn(),
7
+ }));
8
+
9
+ import { processPasswordForTransmission } from './password-transmission';
10
+ import { PublicKeyExchange as identityPublicKeyExchange } from '../generated/api';
11
+
12
+ function sha256hex(input: string): string {
13
+ return createHash('sha256').update(input).digest('hex');
14
+ }
15
+
16
+ const TEST_PEM = `-----BEGIN PUBLIC KEY-----
17
+ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt4R4KXzLBZqHyQNqkLZA
18
+ 6h9YThQq4j+XdKmVF8vPq3HmLcDJ5FfTqM2kVxXCVmNJKHDbGzqP1WyvF5nTbgLV
19
+ w8LkGd2pGHp3RKjCmJxLFHZVZyhWHt7aBPqMqMZGqkJFxD2cHqmNHyDCpHGkNs4b
20
+ GqvBKPxcJnMkFEyFqFf7bSMCj1ARPbHHCq0vT9TmVwLPxkTVNpRjEGLWQzDqJGmF
21
+ n9yIHxR5ckRLHkWKNvUPWyCkNQWHKtKvJWqKjqPpVtLRLHwNmyEYNYgWHPMfmjXZ
22
+ dCqGNpLTHqJBYfCBGHJRFHYZQkFYi6LkUVhFqGxLkNLVNFPEKcZSMcKJqHmPQEtJ
23
+ cQIDAQAB
24
+ -----END PUBLIC KEY-----`;
25
+
26
+ describe('processPasswordForTransmission', () => {
27
+ describe('plain mode', () => {
28
+ it('UT-TX-01: returns password unchanged with transmission=plain', async () => {
29
+ const result = await processPasswordForTransmission(
30
+ 'Test@123',
31
+ 'plain',
32
+ 'acme-corp',
33
+ undefined,
34
+ );
35
+ expect(result.password).toBe('Test@123');
36
+ expect(result.passwordTransmission).toBe('plain');
37
+ });
38
+ });
39
+
40
+ describe('hash mode', () => {
41
+ it('UT-TX-02: returns SHA-256 hex of password|tenantId', async () => {
42
+ const result = await processPasswordForTransmission(
43
+ 'Test@123',
44
+ 'hash',
45
+ 'acme-corp',
46
+ undefined,
47
+ );
48
+ const expected = sha256hex('Test@123|acme-corp');
49
+ expect(result.password).toBe(expected);
50
+ expect(result.passwordTransmission).toBe('hash');
51
+ });
52
+
53
+ it('UT-TX-03: handles empty tenantId', async () => {
54
+ const result = await processPasswordForTransmission('Test@123', 'hash', '', undefined);
55
+ const expected = sha256hex('Test@123|');
56
+ expect(result.password).toBe(expected);
57
+ });
58
+
59
+ it('UT-TX-04: handles chinese characters', async () => {
60
+ const result = await processPasswordForTransmission(
61
+ '测试@123',
62
+ 'hash',
63
+ 'acme-corp',
64
+ undefined,
65
+ );
66
+ const expected = sha256hex('测试@123|acme-corp');
67
+ expect(result.password).toBe(expected);
68
+ expect(result.passwordTransmission).toBe('hash');
69
+ });
70
+ });
71
+
72
+ describe('undefined / default mode', () => {
73
+ it('UT-TX-05: throws when mode is undefined (no silent plain fallback)', async () => {
74
+ await expect(
75
+ processPasswordForTransmission('Test@123', undefined, 'acme-corp', undefined),
76
+ ).rejects.toThrow('password transmission mode is missing from tenant auth-config');
77
+ });
78
+
79
+ it('UT-TX-05b: throws when mode is empty string', async () => {
80
+ await expect(
81
+ processPasswordForTransmission('Test@123', '', 'acme-corp', undefined),
82
+ ).rejects.toThrow('password transmission mode is missing from tenant auth-config');
83
+ });
84
+ });
85
+
86
+ describe('symmetric mode', () => {
87
+ const mockServerPubKey =
88
+ 'BDmXBCDz0nrP5sHAh7ahMBjiIPIqg1lEHfXzw434ChYa/scxqUzjV4vFzw0jFzpKVhvhgIW62xdHKppccUa7sic=';
89
+
90
+ // jsdom limitation: crypto.subtle ECDH P-256 raw key import not supported.
91
+ // The symmetric encryption logic is verified at the Go level:
92
+ // seed/tool/crypto_test.go — TestSymmetricEncrypt_RoundTrip PASS
93
+ // The mock interception is verified by UT-TX-07 (rejection case).
94
+ it.skip('UT-TX-06: returns ciphertext with keyExchangeId (skipped — jsdom crypto.subtle lacks ECDH raw import)', async () => {
95
+ (identityPublicKeyExchange as unknown as ReturnType<typeof vi.fn>).mockImplementation(() =>
96
+ Promise.resolve({ serverPubKey: mockServerPubKey, keyExchangeId: 'test-exchange-1' }),
97
+ );
98
+ try {
99
+ const result = await processPasswordForTransmission(
100
+ 'Test@123',
101
+ 'symmetric',
102
+ 'acme-corp',
103
+ undefined,
104
+ );
105
+ expect(result.passwordTransmission).toBe('symmetric');
106
+ expect(result.password).toBeTruthy();
107
+ expect(typeof result.password).toBe('string');
108
+ expect(result.keyExchangeId).toBe('test-exchange-1');
109
+ expect(result.clientPubKey).toBeTruthy();
110
+ } catch (e) {
111
+ console.error('SYMMETRIC FAILED:', e);
112
+ throw e;
113
+ }
114
+ });
115
+
116
+ it('UT-TX-07: throws when keyExchange fails', async () => {
117
+ vi.mocked(identityPublicKeyExchange).mockRejectedValueOnce(new Error('Network error'));
118
+
119
+ await expect(
120
+ processPasswordForTransmission('Test@123', 'symmetric', 'acme-corp', undefined),
121
+ ).rejects.toThrow();
122
+ });
123
+ });
124
+
125
+ describe('asymmetric mode', () => {
126
+ it('UT-TX-08: returns RSA-OAEP ciphertext', async () => {
127
+ const result = await processPasswordForTransmission(
128
+ 'Test@123',
129
+ 'asymmetric',
130
+ 'acme-corp',
131
+ TEST_PEM,
132
+ );
133
+
134
+ expect(result.passwordTransmission).toBe('asymmetric');
135
+ expect(result.password).toBeTruthy();
136
+ expect(typeof result.password).toBe('string');
137
+ });
138
+
139
+ it('UT-TX-09: throws when publicKey is missing', async () => {
140
+ await expect(
141
+ processPasswordForTransmission('Test@123', 'asymmetric', 'acme-corp', undefined),
142
+ ).rejects.toThrow();
143
+ });
144
+ });
145
+ });