@csntgao/uni-base 0.1.0

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 (90) hide show
  1. package/.prettierignore +4 -0
  2. package/.prettierrc.json +6 -0
  3. package/AGENTS.md +519 -0
  4. package/CLAUDE.md +512 -0
  5. package/README.md +734 -0
  6. package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +110 -0
  7. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/215/225/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +79 -0
  8. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/244/232/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +93 -0
  9. package/docs//350/264/246/346/210/267/344/270/216/347/231/273/345/275/225/347/273/204/344/273/266/345/212/250/346/200/201/346/216/245/345/205/245/350/257/264/346/230/216.md +201 -0
  10. package/eslint.config.js +30 -0
  11. package/examples/vanilla/index.html +33 -0
  12. package/examples/vanilla/main.js +247 -0
  13. package/examples/vanilla/package.json +15 -0
  14. package/examples/vanilla/public/runtime-config.js +10 -0
  15. package/examples/vanilla/style.css +99 -0
  16. package/package.json +31 -0
  17. package/packages/core/package.json +30 -0
  18. package/packages/core/src/client.ts +1132 -0
  19. package/packages/core/src/customer-support-client.ts +825 -0
  20. package/packages/core/src/customer-support-types.ts +123 -0
  21. package/packages/core/src/index.ts +79 -0
  22. package/packages/core/src/notification-center-client.ts +462 -0
  23. package/packages/core/src/notification-center-types.ts +154 -0
  24. package/packages/core/src/protocol.ts +237 -0
  25. package/packages/core/src/types.ts +170 -0
  26. package/packages/core/tests/client.test.ts +760 -0
  27. package/packages/core/tests/customer-support-client.test.ts +576 -0
  28. package/packages/core/tests/fake-websocket.ts +55 -0
  29. package/packages/core/tests/notification-center-client.test.ts +467 -0
  30. package/packages/core/tests/protocol.test.ts +124 -0
  31. package/packages/core/tsconfig.json +7 -0
  32. package/packages/core/tsup.config.ts +11 -0
  33. package/packages/web-components/package.json +35 -0
  34. package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
  35. package/packages/web-components/src/account-context.ts +1540 -0
  36. package/packages/web-components/src/application-switcher-transport.ts +243 -0
  37. package/packages/web-components/src/application-switcher.ts +1103 -0
  38. package/packages/web-components/src/chat.ts +1754 -0
  39. package/packages/web-components/src/customer-support-chat.ts +862 -0
  40. package/packages/web-components/src/customer-support-transport.ts +269 -0
  41. package/packages/web-components/src/index.ts +256 -0
  42. package/packages/web-components/src/notification-center.ts +1066 -0
  43. package/packages/web-components/src/notification-popover.ts +615 -0
  44. package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
  45. package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
  46. package/packages/web-components/src/organization-onboarding.ts +1216 -0
  47. package/packages/web-components/src/organization-selector.ts +534 -0
  48. package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
  49. package/packages/web-components/src/prototype-icons.ts +242 -0
  50. package/packages/web-components/src/safe-image-url.ts +29 -0
  51. package/packages/web-components/src/safe-markdown.ts +192 -0
  52. package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
  53. package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
  54. package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
  55. package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
  56. package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
  57. package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
  58. package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
  59. package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
  60. package/packages/web-components/src/user-login-transport.ts +563 -0
  61. package/packages/web-components/src/user-login.ts +2027 -0
  62. package/packages/web-components/src/user-menu.ts +880 -0
  63. package/packages/web-components/tests/account-context.test.ts +798 -0
  64. package/packages/web-components/tests/application-switcher.test.ts +562 -0
  65. package/packages/web-components/tests/chat.test.ts +1130 -0
  66. package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
  67. package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
  68. package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
  69. package/packages/web-components/tests/notification-center.test.ts +429 -0
  70. package/packages/web-components/tests/notification-popover.test.ts +283 -0
  71. package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
  72. package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
  73. package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
  74. package/packages/web-components/tests/organization-selector.test.ts +127 -0
  75. package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
  76. package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
  77. package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
  78. package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
  79. package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
  80. package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
  81. package/packages/web-components/tests/user-login-transport.test.ts +443 -0
  82. package/packages/web-components/tests/user-login.test.ts +626 -0
  83. package/packages/web-components/tests/user-menu.test.ts +271 -0
  84. package/packages/web-components/tsconfig.json +8 -0
  85. package/packages/web-components/tsup.config.ts +12 -0
  86. package/packages/web-components/tsup.runtime.config.ts +37 -0
  87. package/pnpm-workspace.yaml +6 -0
  88. package/tsconfig.base.json +21 -0
  89. package/vitest.config.ts +20 -0
  90. package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
@@ -0,0 +1,233 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import {
5
+ UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY,
6
+ UNIPLAT_APPLICATION_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY,
7
+ exchangeUniplatBaseApplicationTicket,
8
+ } from '../src'
9
+ import {
10
+ createExternalApplicationTicketLaunchResult,
11
+ resolveUniplatBaseApplicationEntryUrl,
12
+ } from '../src/uniplat-base-application-ticket'
13
+
14
+ const serviceBaseUrl = 'https://api.example.com/api/general/project/uniplat_base/service'
15
+ const exchangeEndpoint = `${serviceBaseUrl}/anonymous/application.identity/exchange_application_ticket`
16
+ const applicationTicket = 'ticket_for_target_exchange_123456789'
17
+
18
+ describe('exchangeUniplatBaseApplicationTicket()', () => {
19
+ afterEach(() => {
20
+ window.history.replaceState(null, '', '/')
21
+ vi.restoreAllMocks()
22
+ })
23
+
24
+ it('cleans the fragment before exchanging the ticket and hands JWT only to Host', async () => {
25
+ window.history.replaceState(
26
+ null,
27
+ '',
28
+ `/application/callback?source=switcher#${UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY}=${applicationTicket}`,
29
+ )
30
+ const accessToken = 'target.application.jwt'
31
+ const setAccessToken = vi.fn(async (token: string) => {
32
+ void token
33
+ })
34
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
35
+ void input
36
+ void init
37
+ expect(window.location.hash).toBe('')
38
+ expect(window.location.pathname).toBe('/application/callback')
39
+ expect(window.location.search).toBe('?source=switcher')
40
+ return new Response(
41
+ JSON.stringify({
42
+ rescode: 0,
43
+ data: { token: accessToken, application_name: 'HrSaas' },
44
+ }),
45
+ { status: 200, headers: { 'content-type': 'application/json' } },
46
+ )
47
+ })
48
+
49
+ await expect(
50
+ exchangeUniplatBaseApplicationTicket({
51
+ serviceBaseUrl,
52
+ applicationName: 'HrSaas',
53
+ setAccessToken,
54
+ fetch: fetchMock as typeof fetch,
55
+ }),
56
+ ).resolves.toBe(true)
57
+
58
+ expect(fetchMock).toHaveBeenCalledTimes(1)
59
+ const [url, init] = fetchMock.mock.calls[0]!
60
+ expect(url).toBe(exchangeEndpoint)
61
+ expect(init?.method).toBe('POST')
62
+ expect(init?.credentials).toBe('omit')
63
+ expect(init?.body).toBe(JSON.stringify({ ticket: applicationTicket }))
64
+ expect(new Headers(init?.headers).has('authorization')).toBe(false)
65
+ expect(setAccessToken).toHaveBeenCalledWith(accessToken)
66
+ expect(window.location.href).not.toContain(applicationTicket)
67
+ expect(JSON.stringify(setAccessToken.mock.calls)).toContain(accessToken)
68
+ })
69
+
70
+ it('does nothing when the target page has no application ticket', async () => {
71
+ const fetchMock = vi.fn()
72
+ const setAccessToken = vi.fn()
73
+
74
+ await expect(
75
+ exchangeUniplatBaseApplicationTicket({
76
+ serviceBaseUrl,
77
+ applicationName: 'HrSaas',
78
+ setAccessToken,
79
+ fetch: fetchMock as typeof fetch,
80
+ }),
81
+ ).resolves.toBe(false)
82
+
83
+ expect(fetchMock).not.toHaveBeenCalled()
84
+ expect(setAccessToken).not.toHaveBeenCalled()
85
+ })
86
+
87
+ it('forwards source_system transparently and logs the same complete ticket when read and submitted', async () => {
88
+ const sourceSystem = 'ae-employee'
89
+ window.history.replaceState(
90
+ null,
91
+ '',
92
+ `/application/callback#keep=value&${UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY}=${applicationTicket}&${UNIPLAT_APPLICATION_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY}=${sourceSystem}`,
93
+ )
94
+ const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => undefined)
95
+ const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
96
+ expect(window.location.hash).toBe('#keep=value')
97
+ expect(init?.body).toBe(
98
+ JSON.stringify({
99
+ ticket: applicationTicket,
100
+ source_system: sourceSystem,
101
+ application_name: 'HrSaas',
102
+ }),
103
+ )
104
+ return new Response(
105
+ JSON.stringify({
106
+ rescode: 0,
107
+ data: { token: 'target.application.jwt', application_name: 'HrSaas' },
108
+ }),
109
+ { status: 200, headers: { 'content-type': 'application/json' } },
110
+ )
111
+ })
112
+
113
+ await expect(
114
+ exchangeUniplatBaseApplicationTicket({
115
+ serviceBaseUrl,
116
+ applicationName: 'HrSaas',
117
+ setAccessToken: vi.fn(),
118
+ fetch: fetchMock as typeof fetch,
119
+ }),
120
+ ).resolves.toBe(true)
121
+
122
+ expect(consoleInfo).not.toHaveBeenCalled()
123
+ expect(window.location.href).not.toContain(applicationTicket)
124
+ expect(window.location.href).not.toContain(sourceSystem)
125
+ })
126
+
127
+ it('returns only a stable error and still removes an invalid ticket from the URL', async () => {
128
+ window.history.replaceState(
129
+ null,
130
+ '',
131
+ `/application/callback#${UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY}=${applicationTicket}`,
132
+ )
133
+ const fetchMock = vi.fn(
134
+ async () =>
135
+ new Response(
136
+ JSON.stringify({ rescode: 403, msg: 'internal ticket store details', data: {} }),
137
+ { status: 200, headers: { 'content-type': 'application/json' } },
138
+ ),
139
+ )
140
+ const setAccessToken = vi.fn()
141
+
142
+ const error = await exchangeUniplatBaseApplicationTicket({
143
+ serviceBaseUrl,
144
+ applicationName: 'HrSaas',
145
+ setAccessToken,
146
+ fetch: fetchMock as typeof fetch,
147
+ }).catch((reason: unknown) => reason)
148
+
149
+ expect(String(error)).toBe('Error: Application ticket exchange failed')
150
+ expect(String(error)).not.toContain('internal ticket')
151
+ expect(window.location.hash).toBe('')
152
+ expect(setAccessToken).not.toHaveBeenCalled()
153
+ })
154
+
155
+ it('exposes only a recognized target federated error message', async () => {
156
+ window.history.replaceState(
157
+ null,
158
+ '',
159
+ `/application/callback#${UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY}=${applicationTicket}&source_system=ae-employee`,
160
+ )
161
+ const fetchMock = vi.fn(
162
+ async () =>
163
+ new Response(
164
+ JSON.stringify({
165
+ rescode: 999,
166
+ error_code: 'FEDERATED_IDENTITY_NOT_READY',
167
+ msg: '用户或组织关系尚未同步,请联系管理员后重新登录',
168
+ }),
169
+ { status: 200, headers: { 'content-type': 'application/json' } },
170
+ ),
171
+ )
172
+
173
+ await expect(
174
+ exchangeUniplatBaseApplicationTicket({
175
+ serviceBaseUrl,
176
+ applicationName: 'HrSaas',
177
+ setAccessToken: vi.fn(),
178
+ fetch: fetchMock as typeof fetch,
179
+ }),
180
+ ).rejects.toThrow('用户或组织关系尚未同步,请联系管理员后重新登录')
181
+ })
182
+ })
183
+
184
+ describe('resolveUniplatBaseApplicationEntryUrl()', () => {
185
+ it('resolves a relative entry against the current document before ticket signing', () => {
186
+ window.history.replaceState(null, '', '/host/page')
187
+
188
+ expect(resolveUniplatBaseApplicationEntryUrl('/personal')).toBe(
189
+ new URL('/personal', document.baseURI).href,
190
+ )
191
+ })
192
+
193
+ it('rejects credentialed or fragment entry URLs before ticket signing', () => {
194
+ expect(() =>
195
+ resolveUniplatBaseApplicationEntryUrl('https://user@example.com/personal'),
196
+ ).toThrow('Application entry URL is invalid')
197
+ expect(() =>
198
+ resolveUniplatBaseApplicationEntryUrl('https://example.com/personal#fragment'),
199
+ ).toThrow('Application entry URL is invalid')
200
+ })
201
+ })
202
+
203
+ describe('createExternalApplicationTicketLaunchResult()', () => {
204
+ const redirectUrl =
205
+ `http://a.web.test.hrs100.cn/oidc-redirect` +
206
+ `#uniplat_application_ticket=${applicationTicket}` +
207
+ '&source_system=ae-employee&application_name=HRS100'
208
+
209
+ it('accepts the backend redirect only when ticket and target application stay bound', () => {
210
+ expect(
211
+ createExternalApplicationTicketLaunchResult(redirectUrl, applicationTicket, 15, 'HRS100'),
212
+ ).toEqual({ url: redirectUrl })
213
+ })
214
+
215
+ it('rejects mismatched or extended external redirect fragments', () => {
216
+ expect(() =>
217
+ createExternalApplicationTicketLaunchResult(
218
+ redirectUrl.replace('application_name=HRS100', 'application_name=Other'),
219
+ applicationTicket,
220
+ 15,
221
+ 'HRS100',
222
+ ),
223
+ ).toThrow('External application redirect URL is invalid')
224
+ expect(() =>
225
+ createExternalApplicationTicketLaunchResult(
226
+ `${redirectUrl}&unexpected=value`,
227
+ applicationTicket,
228
+ 15,
229
+ 'HRS100',
230
+ ),
231
+ ).toThrow('External application redirect URL is invalid')
232
+ })
233
+ })
@@ -0,0 +1,226 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import {
5
+ UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY,
6
+ UNIPLAT_IDENTITY_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY,
7
+ loginWithUniplatBaseIdentityTicket,
8
+ } from '../src'
9
+ import { createIdentityTicketLaunchResult } from '../src/uniplat-base-identity-ticket'
10
+
11
+ const serviceBaseUrl = 'https://api.example.com/api/general/project/uniplat_base/service'
12
+ const loginEndpoint = `${serviceBaseUrl}/anonymous/application.identity/login_with_ticket`
13
+ const identityTicket = 'identity_ticket_for_login_1234567890'
14
+ const sourceSystem = 'ae-employee'
15
+
16
+ describe('loginWithUniplatBaseIdentityTicket()', () => {
17
+ afterEach(() => {
18
+ window.history.replaceState(null, '', '/')
19
+ vi.restoreAllMocks()
20
+ })
21
+
22
+ it('uses the configured application and clears only identity ticket fragment fields', async () => {
23
+ window.history.replaceState(
24
+ null,
25
+ '',
26
+ `/callback?from=workbench#keep=value&${UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY}=${identityTicket}&${UNIPLAT_IDENTITY_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY}=${sourceSystem}&application=Untrusted`,
27
+ )
28
+ const accessToken = 'target.identity.jwt'
29
+ const setAccessToken = vi.fn(async (token: string) => {
30
+ void token
31
+ })
32
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
33
+ void input
34
+ void init
35
+ expect(window.location.hash).toBe('#keep=value&application=Untrusted')
36
+ return new Response(
37
+ JSON.stringify({
38
+ rescode: 0,
39
+ data: { token: accessToken, application_name: 'HrSaas' },
40
+ }),
41
+ { status: 200, headers: { 'content-type': 'application/json' } },
42
+ )
43
+ })
44
+
45
+ await expect(
46
+ loginWithUniplatBaseIdentityTicket({
47
+ serviceBaseUrl,
48
+ applicationName: 'HrSaas',
49
+ setAccessToken,
50
+ fetch: fetchMock as typeof fetch,
51
+ }),
52
+ ).resolves.toBe(true)
53
+
54
+ const [url, init] = fetchMock.mock.calls[0]!
55
+ expect(url).toBe(loginEndpoint)
56
+ expect(init?.method).toBe('POST')
57
+ expect(init?.credentials).toBe('omit')
58
+ expect(init?.body).toBe(
59
+ JSON.stringify({
60
+ application: 'HrSaas',
61
+ ticket: identityTicket,
62
+ source_system: sourceSystem,
63
+ }),
64
+ )
65
+ expect(String(init?.body)).not.toContain('client_id')
66
+ expect(String(init?.body)).not.toContain('Untrusted')
67
+ expect(new Headers(init?.headers).has('authorization')).toBe(false)
68
+ expect(setAccessToken).toHaveBeenCalledWith(accessToken)
69
+ expect(window.location.href).not.toContain(identityTicket)
70
+ expect(window.location.href).not.toContain(sourceSystem)
71
+ })
72
+
73
+ it('omits source_system when the fragment does not provide it', async () => {
74
+ window.history.replaceState(
75
+ null,
76
+ '',
77
+ `/callback#${UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY}=${identityTicket}`,
78
+ )
79
+ const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
80
+ expect(init?.body).toBe(
81
+ JSON.stringify({ application: 'OfficialWebsite', ticket: identityTicket }),
82
+ )
83
+ return new Response(
84
+ JSON.stringify({
85
+ rescode: 0,
86
+ data: { token: 'local.identity.jwt', application_name: 'OfficialWebsite' },
87
+ }),
88
+ { status: 200, headers: { 'content-type': 'application/json' } },
89
+ )
90
+ })
91
+
92
+ await expect(
93
+ loginWithUniplatBaseIdentityTicket({
94
+ serviceBaseUrl,
95
+ applicationName: 'OfficialWebsite',
96
+ setAccessToken: vi.fn(),
97
+ fetch: fetchMock as typeof fetch,
98
+ }),
99
+ ).resolves.toBe(true)
100
+ })
101
+
102
+ it('does nothing when the URL has no identity ticket', async () => {
103
+ const fetchMock = vi.fn()
104
+ const setAccessToken = vi.fn()
105
+ await expect(
106
+ loginWithUniplatBaseIdentityTicket({
107
+ serviceBaseUrl,
108
+ applicationName: 'HrSaas',
109
+ setAccessToken,
110
+ fetch: fetchMock as typeof fetch,
111
+ }),
112
+ ).resolves.toBe(false)
113
+ expect(fetchMock).not.toHaveBeenCalled()
114
+ expect(setAccessToken).not.toHaveBeenCalled()
115
+ })
116
+
117
+ it('removes an invalid ticket before returning a stable error', async () => {
118
+ window.history.replaceState(
119
+ null,
120
+ '',
121
+ `/callback#keep=value&${UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY}=invalid`,
122
+ )
123
+ const setAccessToken = vi.fn()
124
+ await expect(
125
+ loginWithUniplatBaseIdentityTicket({
126
+ serviceBaseUrl,
127
+ applicationName: 'HrSaas',
128
+ setAccessToken,
129
+ fetch: vi.fn() as typeof fetch,
130
+ }),
131
+ ).rejects.toThrow('身份票据登录失败')
132
+ expect(window.location.hash).toBe('#keep=value')
133
+ expect(setAccessToken).not.toHaveBeenCalled()
134
+ })
135
+
136
+ it('does not persist a JWT for a mismatched response application', async () => {
137
+ window.history.replaceState(
138
+ null,
139
+ '',
140
+ `/callback#${UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY}=${identityTicket}`,
141
+ )
142
+ const setAccessToken = vi.fn()
143
+ const fetchMock = vi.fn(
144
+ async () =>
145
+ new Response(
146
+ JSON.stringify({
147
+ rescode: 0,
148
+ data: { token: 'wrong.application.jwt', application_name: 'Other' },
149
+ }),
150
+ { status: 200, headers: { 'content-type': 'application/json' } },
151
+ ),
152
+ )
153
+ await expect(
154
+ loginWithUniplatBaseIdentityTicket({
155
+ serviceBaseUrl,
156
+ applicationName: 'HrSaas',
157
+ setAccessToken,
158
+ fetch: fetchMock as typeof fetch,
159
+ }),
160
+ ).rejects.toThrow('身份票据登录失败')
161
+ expect(setAccessToken).not.toHaveBeenCalled()
162
+ })
163
+
164
+ it('exposes only recognized identity ticket error messages', async () => {
165
+ window.history.replaceState(
166
+ null,
167
+ '',
168
+ `/callback#${UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY}=${identityTicket}`,
169
+ )
170
+ const fetchMock = vi.fn(
171
+ async () =>
172
+ new Response(
173
+ JSON.stringify({
174
+ rescode: 999,
175
+ error_code: 'IDENTITY_TICKET_IDENTITY_NOT_READY',
176
+ msg: '用户或组织关系尚未就绪',
177
+ }),
178
+ { status: 200, headers: { 'content-type': 'application/json' } },
179
+ ),
180
+ )
181
+ await expect(
182
+ loginWithUniplatBaseIdentityTicket({
183
+ serviceBaseUrl,
184
+ applicationName: 'HrSaas',
185
+ setAccessToken: vi.fn(),
186
+ fetch: fetchMock as typeof fetch,
187
+ }),
188
+ ).rejects.toThrow('用户或组织关系尚未就绪')
189
+ })
190
+ })
191
+
192
+ describe('createIdentityTicketLaunchResult()', () => {
193
+ it('uses the selected entry URL and authoritative source system', () => {
194
+ expect(
195
+ createIdentityTicketLaunchResult(
196
+ 'https://target.example.com/application',
197
+ identityTicket,
198
+ 15,
199
+ sourceSystem,
200
+ ),
201
+ ).toEqual({
202
+ url:
203
+ `https://target.example.com/application#${UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY}=${identityTicket}` +
204
+ `&${UNIPLAT_IDENTITY_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY}=${sourceSystem}`,
205
+ })
206
+ })
207
+
208
+ it('rejects missing source system or a non-15-second lifetime', () => {
209
+ expect(() =>
210
+ createIdentityTicketLaunchResult(
211
+ 'https://target.example.com/application',
212
+ identityTicket,
213
+ 14,
214
+ sourceSystem,
215
+ ),
216
+ ).toThrow('Identity ticket response is invalid')
217
+ expect(() =>
218
+ createIdentityTicketLaunchResult(
219
+ 'https://target.example.com/application',
220
+ identityTicket,
221
+ 15,
222
+ '',
223
+ ),
224
+ ).toThrow('Identity ticket response is invalid')
225
+ })
226
+ })
@@ -0,0 +1,227 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import {
5
+ createUniplatBaseOrganizationOnboardingTransport,
6
+ OrganizationOnboardingTransportError,
7
+ } from '../src'
8
+
9
+ const serviceBaseUrl = 'https://api.example.com/api/general/project/uniplat_base/service'
10
+ const createEndpoint = `${serviceBaseUrl}/system.org/create_organization`
11
+
12
+ async function captureTransportError(
13
+ request: Promise<unknown>,
14
+ ): Promise<OrganizationOnboardingTransportError> {
15
+ try {
16
+ await request
17
+ } catch (error) {
18
+ expect(error).toBeInstanceOf(OrganizationOnboardingTransportError)
19
+ return error as OrganizationOnboardingTransportError
20
+ }
21
+ throw new Error('Expected organization onboarding transport to fail')
22
+ }
23
+
24
+ describe('createUniplatBaseOrganizationOnboardingTransport()', () => {
25
+ it('creates an enterprise with the current JWT only in the Authorization header', async () => {
26
+ const token = 'obviously-fake-onboarding-jwt'
27
+ const getAccessToken = vi.fn(async () => token)
28
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
29
+ void input
30
+ void init
31
+ return new Response(
32
+ JSON.stringify({
33
+ rescode: 0,
34
+ data: {
35
+ oid: '136',
36
+ eid: '2048',
37
+ team_name: '星河科技',
38
+ customer_name: '北京星河科技有限公司',
39
+ organization_type: 'enterprise',
40
+ verification_status: 'unverified',
41
+ },
42
+ }),
43
+ { status: 200, headers: { 'content-type': 'application/json' } },
44
+ )
45
+ })
46
+ const transport = createUniplatBaseOrganizationOnboardingTransport({
47
+ serviceBaseUrl,
48
+ getAccessToken,
49
+ fetch: fetchMock as typeof fetch,
50
+ })
51
+
52
+ await expect(
53
+ transport.createEnterprise({
54
+ organizationName: ' 星河科技 ',
55
+ enterpriseName: ' 北京星河科技有限公司 ',
56
+ unifiedSocialCreditCode: '91110108ma01abc123',
57
+ }),
58
+ ).resolves.toEqual({
59
+ id: '136',
60
+ name: '北京星河科技有限公司',
61
+ type: 'enterprise',
62
+ })
63
+
64
+ expect(getAccessToken).toHaveBeenCalledTimes(1)
65
+ expect(fetchMock).toHaveBeenCalledTimes(1)
66
+ const [url, init] = fetchMock.mock.calls[0]!
67
+ expect(url).toBe(createEndpoint)
68
+ expect(init?.method).toBe('POST')
69
+ expect(init?.credentials).toBe('include')
70
+ expect(init?.cache).toBe('no-store')
71
+ expect(init?.body).toBe(
72
+ JSON.stringify({
73
+ organization_type: 'enterprise',
74
+ team_name: '星河科技',
75
+ customer_name: '北京星河科技有限公司',
76
+ credit_code: '91110108MA01ABC123',
77
+ }),
78
+ )
79
+ expect(String(url)).not.toContain(token)
80
+ expect(String(init?.body)).not.toContain(token)
81
+ expect(new Headers(init?.headers).get('authorization')).toBe(`Bearer ${token}`)
82
+ })
83
+
84
+ it('creates a team and gets a fresh token for every request', async () => {
85
+ const getAccessToken = vi
86
+ .fn<() => Promise<string>>()
87
+ .mockResolvedValueOnce('obviously-fake-first-jwt')
88
+ .mockResolvedValueOnce('obviously-fake-second-jwt')
89
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
90
+ void input
91
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>
92
+ const enterprise = body.organization_type === 'enterprise'
93
+ return new Response(
94
+ JSON.stringify({
95
+ rescode: 0,
96
+ data: enterprise
97
+ ? {
98
+ oid: 137,
99
+ team_name: '第二企业',
100
+ customer_name: '第二企业有限公司',
101
+ organization_type: 'enterprise',
102
+ }
103
+ : {
104
+ oid: 138,
105
+ team_name: '星河增长项目组',
106
+ customer_name: null,
107
+ organization_type: 'team',
108
+ },
109
+ }),
110
+ { status: 200, headers: { 'content-type': 'application/json' } },
111
+ )
112
+ })
113
+ const transport = createUniplatBaseOrganizationOnboardingTransport({
114
+ serviceBaseUrl,
115
+ getAccessToken,
116
+ fetch: fetchMock as typeof fetch,
117
+ })
118
+
119
+ await transport.createEnterprise({
120
+ organizationName: '第二企业',
121
+ enterpriseName: '第二企业有限公司',
122
+ unifiedSocialCreditCode: '91110108MA01DEF456',
123
+ })
124
+ await expect(transport.createTeam({ teamName: ' 星河增长项目组 ' })).resolves.toEqual({
125
+ id: '138',
126
+ name: '星河增长项目组',
127
+ type: 'team',
128
+ })
129
+
130
+ expect(getAccessToken).toHaveBeenCalledTimes(2)
131
+ expect(fetchMock).toHaveBeenCalledTimes(2)
132
+ const [, firstInit] = fetchMock.mock.calls[0]!
133
+ const [, secondInit] = fetchMock.mock.calls[1]!
134
+ expect(new Headers(firstInit?.headers).get('authorization')).toBe(
135
+ 'Bearer obviously-fake-first-jwt',
136
+ )
137
+ expect(new Headers(secondInit?.headers).get('authorization')).toBe(
138
+ 'Bearer obviously-fake-second-jwt',
139
+ )
140
+ expect(secondInit?.body).toBe(
141
+ JSON.stringify({ organization_type: 'team', team_name: '星河增长项目组' }),
142
+ )
143
+ })
144
+
145
+ it.each(['ENTERPRISE_NAME_CONFLICT', 'CREDIT_CODE_CONFLICT', 'TEAM_NAME_CONFLICT'] as const)(
146
+ 'maps the stable backend error code %s',
147
+ async (errorCode) => {
148
+ const fetchMock = vi.fn(async () => {
149
+ return new Response(
150
+ JSON.stringify({ rescode: 999, error_code: errorCode, msg: '不应透传的后端正文' }),
151
+ { status: 200, headers: { 'content-type': 'application/json' } },
152
+ )
153
+ })
154
+ const transport = createUniplatBaseOrganizationOnboardingTransport({
155
+ serviceBaseUrl,
156
+ getAccessToken: () => 'obviously-fake-error-jwt',
157
+ fetch: fetchMock as typeof fetch,
158
+ })
159
+
160
+ const error = await captureTransportError(transport.createTeam({ teamName: '冲突团队' }))
161
+
162
+ expect(error?.code).toBe(errorCode)
163
+ expect(error?.message).toBe(errorCode)
164
+ expect(error?.message).not.toContain('不应透传')
165
+ },
166
+ )
167
+
168
+ it('maps unknown backend, token-provider and malformed-response failures to UNKNOWN', async () => {
169
+ const backendFailure = createUniplatBaseOrganizationOnboardingTransport({
170
+ serviceBaseUrl,
171
+ getAccessToken: () => 'obviously-fake-unknown-jwt',
172
+ fetch: vi.fn(async () => {
173
+ return new Response(
174
+ JSON.stringify({
175
+ rescode: 999,
176
+ error_code: 'ORGANIZATION_CREATE_FAILED',
177
+ msg: 'database stack must stay private',
178
+ }),
179
+ { status: 200, headers: { 'content-type': 'application/json' } },
180
+ )
181
+ }) as typeof fetch,
182
+ })
183
+ const tokenFailure = createUniplatBaseOrganizationOnboardingTransport({
184
+ serviceBaseUrl,
185
+ getAccessToken() {
186
+ throw new Error('private token provider detail')
187
+ },
188
+ fetch: vi.fn() as typeof fetch,
189
+ })
190
+ const malformedResponse = createUniplatBaseOrganizationOnboardingTransport({
191
+ serviceBaseUrl,
192
+ getAccessToken: () => 'obviously-fake-malformed-jwt',
193
+ fetch: vi.fn(async () => {
194
+ return new Response(
195
+ JSON.stringify({
196
+ rescode: 0,
197
+ data: { oid: '139', organization_type: 'team', team_name: '' },
198
+ }),
199
+ { status: 200, headers: { 'content-type': 'application/json' } },
200
+ )
201
+ }) as typeof fetch,
202
+ })
203
+
204
+ for (const transport of [backendFailure, tokenFailure, malformedResponse]) {
205
+ const error = await captureTransportError(transport.createTeam({ teamName: '安全团队' }))
206
+ expect(error?.code).toBe('UNKNOWN')
207
+ expect(error?.message).toBe('UNKNOWN')
208
+ }
209
+ })
210
+
211
+ it('rejects unsafe service URLs before requesting a token', () => {
212
+ const getAccessToken = vi.fn(() => 'obviously-fake-unused-jwt')
213
+ expect(() =>
214
+ createUniplatBaseOrganizationOnboardingTransport({
215
+ serviceBaseUrl: 'https://user:password@example.com/service',
216
+ getAccessToken,
217
+ }),
218
+ ).toThrow(TypeError)
219
+ expect(() =>
220
+ createUniplatBaseOrganizationOnboardingTransport({
221
+ serviceBaseUrl: 'https://api.example.com/service?jwt=unsafe',
222
+ getAccessToken,
223
+ }),
224
+ ).toThrow(TypeError)
225
+ expect(getAccessToken).not.toHaveBeenCalled()
226
+ })
227
+ })