@open-xchange/soap-client 0.1.6 → 0.2.1

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.
@@ -1,52 +1,67 @@
1
- import { createClientAsync } from '../soap.js'
2
-
3
- let OXSecondaryAccountService
4
- function getClient () {
5
- if (!OXSecondaryAccountService) OXSecondaryAccountService = createClientAsync('OXSecondaryAccountService')
6
- return OXSecondaryAccountService
7
- }
1
+ import { createClientAsync, memoizeClient } from '../soap.js'
8
2
 
9
3
  /**
10
- * Creates a secondary account using the OX Secondary Account Service
11
- * @async
12
- * @param {Object} accountData - The data for the secondary account to be created
13
- * @param {Object} context - The context object where the account will be created
14
- * @param {Array<Object>} users - Array of users to associate with the secondary account
15
- * @param {Array<Object>} groups - Array of groups to associate with the secondary account
16
- * @returns {Promise<Object>} The created secondary account object
17
- * @throws {Error} If the account creation fails
4
+ * Build a secondary-account service bound to a SOAP client constructor.
5
+ * @param {(type: string) => Promise<Object>} createClient
6
+ * @returns {Object} The secondary account service.
18
7
  */
19
- export async function create (accountData, context, users = [], groups = []) {
20
- return (await getClient()).createAsync({
21
- accountDataOnCreate: accountData,
22
- context: { id: context.id },
23
- users: users || [],
24
- auth: context.admin
25
- })
26
- }
8
+ export function createSecondaryAccountService (createClient) {
9
+ const getClient = memoizeClient(createClient, 'OXSecondaryAccountService')
27
10
 
28
- export async function list (context) {
29
- return (await getClient()).listAsync({
30
- context: { id: context.id },
31
- auth: context.admin
32
- })
33
- }
34
- export async function remove (primaryAddress, context, users, groups) {
35
- return (await getClient()).deleteAsync({
36
- primaryAddress,
37
- context: { id: context.id },
38
- users,
39
- auth: context.admin
40
- })
41
- }
11
+ /**
12
+ * Creates a secondary account using the OX Secondary Account Service
13
+ * @async
14
+ * @param {Object} accountData - The data for the secondary account to be created
15
+ * @param {Object} context - The context object where the account will be created
16
+ * @param {Array<Object>} users - Array of users to associate with the secondary account
17
+ * @param {Array<Object>} groups - Array of groups to associate with the secondary account
18
+ * @returns {Promise<Object>} The created secondary account object
19
+ * @throws {Error} If the account creation fails
20
+ */
21
+ async function create (accountData, context, users = [], groups = []) {
22
+ return (await getClient()).createAsync({
23
+ accountDataOnCreate: accountData,
24
+ context: { id: context.id },
25
+ users: users || [],
26
+ auth: context.admin
27
+ })
28
+ }
42
29
 
43
- export async function update (primaryAddress, accountData, context, users, groups) {
44
- return (await getClient()).updateAsync({
45
- primaryAddress,
46
- accountDataUpdate: accountData,
47
- context: { id: context.id },
48
- users,
49
- groups,
50
- auth: context.admin
51
- })
30
+ async function list (context) {
31
+ return (await getClient()).listAsync({
32
+ context: { id: context.id },
33
+ auth: context.admin
34
+ })
35
+ }
36
+
37
+ async function remove (primaryAddress, context, users, groups) {
38
+ return (await getClient()).deleteAsync({
39
+ primaryAddress,
40
+ context: { id: context.id },
41
+ users,
42
+ auth: context.admin
43
+ })
44
+ }
45
+
46
+ async function update (primaryAddress, accountData, context, users, groups) {
47
+ return (await getClient()).updateAsync({
48
+ primaryAddress,
49
+ accountDataUpdate: accountData,
50
+ context: { id: context.id },
51
+ users,
52
+ groups,
53
+ auth: context.admin
54
+ })
55
+ }
56
+
57
+ return { create, list, remove, update }
52
58
  }
59
+
60
+ // Classic module exports, bound to the env-configured default endpoint.
61
+ let defaultService
62
+ const service = () => (defaultService ??= createSecondaryAccountService(createClientAsync))
63
+
64
+ export const create = (...args) => service().create(...args)
65
+ export const list = (...args) => service().list(...args)
66
+ export const remove = (...args) => service().remove(...args)
67
+ export const update = (...args) => service().update(...args)
@@ -18,116 +18,132 @@
18
18
  * Any use of the work other than as authorized under this license or copyright law is prohibited.
19
19
  */
20
20
 
21
- import { createClientAsync } from '../soap.js'
22
-
23
- let OXSharedAccountService
24
- function getClient () {
25
- if (!OXSharedAccountService) OXSharedAccountService = createClientAsync('OXSharedAccountService')
26
- return OXSharedAccountService
27
- }
21
+ import { createClientAsync, memoizeClient } from '../soap.js'
28
22
 
29
23
  /**
30
- * Creates a shared account using the OX Shared Account Service
31
- *
32
- * @param {Object} context - The context object where the account will be created
33
- * @param {Object} sharedAccountData - The data for the shared account to be created
34
- * @returns {Promise<Object>} The created shared account object
35
- * @throws {Error} If the shared account creation fails
24
+ * Build a shared-account service bound to a SOAP client constructor.
25
+ * @param {(type: string) => Promise<Object>} createClient
26
+ * @returns {Object} The shared account service.
36
27
  */
37
- export async function create (context, sharedAccountData) {
38
- return (await getClient()).createAsync({
39
- ctx: { id: context.id },
40
- sharedAccountData, // display_name, given_name, imapServer, language, mailenabled, name, sur_name, primaryEmail, email1, smtpServer, timezone, password
41
- auth: context.admin
42
- })
43
- }
28
+ export function createSharedAccountService (createClient) {
29
+ const getClient = memoizeClient(createClient, 'OXSharedAccountService')
44
30
 
45
- /**
46
- * Converts an existing user to a shared account using the OX Shared Account Service
47
- *
48
- * @param {Object} context - The context of the user to be converted
49
- * @param {number} userId - ID of the user to be converted
50
- * @param {string} password - The password for the new shared account
51
- * @returns {Promise<void>}
52
- * @throws {Error} If the conversion fails
53
- */
54
- export async function convertUserToSharedAccount (context, userId, password) {
55
- return (await getClient()).convertUserToSharedAccountAsync({
56
- ctx: { id: context.id },
57
- user: { id: userId },
58
- password,
59
- auth: context.admin
60
- })
61
- }
31
+ /**
32
+ * Creates a shared account using the OX Shared Account Service
33
+ *
34
+ * @param {Object} context - The context object where the account will be created
35
+ * @param {Object} sharedAccountData - The data for the shared account to be created
36
+ * @returns {Promise<Object>} The created shared account object
37
+ * @throws {Error} If the shared account creation fails
38
+ */
39
+ async function create (context, sharedAccountData) {
40
+ return (await getClient()).createAsync({
41
+ ctx: { id: context.id },
42
+ sharedAccountData, // display_name, given_name, imapServer, language, mailenabled, name, sur_name, primaryEmail, email1, smtpServer, timezone, password
43
+ auth: context.admin
44
+ })
45
+ }
62
46
 
63
- /**
64
- * Add users and groups with specified permissions and capabilities to an existing shared account using the OX Shared Account Service.
65
- *
66
- * @param {Object} context - The context of the users and groups
67
- * @param {Object} sharedAccountContext - The context of the shared account
68
- * @param {number} sharedAccountId - ID of the shared account
69
- * @param {Object} permissions - The users, groups, capabilities, and module-specific permissions.
70
- * @returns {Promise<Object>} The shared account object
71
- * @throws {Error} If the creation of the shared account permissions fails
72
- */
73
- export async function createSharedAccountPermissions (context, sharedAccountContext, sharedAccountId, permissions) {
74
- return (await getClient()).createSharedAccountPermissionsAsync({
75
- ctx: { id: context.id },
76
- sharedAccountCtx: { id: sharedAccountContext.id },
77
- sharedAccount: { id: sharedAccountId },
78
- groups: permissions.groups ?? [],
79
- users: permissions.users?.map(user => ({ id: user.userdata.id })) ?? [],
80
- capabilities: {
81
- grantedCapability: permissions.capabilities?.grantedCapability ?? [],
82
- deniedCapability: permissions.capabilities?.deniedCapability ?? [],
83
- },
84
- mailConfig: permissions.mailConfig,
85
- calendarConfig: permissions.calendarConfig,
86
- auth: context.admin
87
- })
88
- }
47
+ /**
48
+ * Converts an existing user to a shared account using the OX Shared Account Service
49
+ *
50
+ * @param {Object} context - The context of the user to be converted
51
+ * @param {number} userId - ID of the user to be converted
52
+ * @param {string} password - The password for the new shared account
53
+ * @returns {Promise<void>}
54
+ * @throws {Error} If the conversion fails
55
+ */
56
+ async function convertUserToSharedAccount (context, userId, password) {
57
+ return (await getClient()).convertUserToSharedAccountAsync({
58
+ ctx: { id: context.id },
59
+ user: { id: userId },
60
+ password,
61
+ auth: context.admin
62
+ })
63
+ }
89
64
 
90
- /**
91
- * List all shared accounts in the specified context
92
- *
93
- * @param {Object} context - The context object whose shared account will be listed
94
- * @param {*} pattern - An optional search pattern for the contexts
95
- * @returns {Promise<Object[]>} The list of shared account objects
96
- */
97
- export async function list (context, pattern = '*') {
98
- return (await getClient()).listAsync({
99
- ctx: { id: context.id },
100
- search_pattern: pattern,
101
- auth: context.admin
102
- })
103
- }
65
+ /**
66
+ * Add users and groups with specified permissions and capabilities to an existing shared account using the OX Shared Account Service.
67
+ *
68
+ * @param {Object} context - The context of the users and groups
69
+ * @param {Object} sharedAccountContext - The context of the shared account
70
+ * @param {number} sharedAccountId - ID of the shared account
71
+ * @param {Object} permissions - The users, groups, capabilities, and module-specific permissions.
72
+ * @returns {Promise<Object>} The shared account object
73
+ * @throws {Error} If the creation of the shared account permissions fails
74
+ */
75
+ async function createSharedAccountPermissions (context, sharedAccountContext, sharedAccountId, permissions) {
76
+ return (await getClient()).createSharedAccountPermissionsAsync({
77
+ ctx: { id: context.id },
78
+ sharedAccountCtx: { id: sharedAccountContext.id },
79
+ sharedAccount: { id: sharedAccountId },
80
+ groups: permissions.groups ?? [],
81
+ users: permissions.users?.map(user => ({ id: user.userdata.id })) ?? [],
82
+ capabilities: {
83
+ grantedCapability: permissions.capabilities?.grantedCapability ?? [],
84
+ deniedCapability: permissions.capabilities?.deniedCapability ?? [],
85
+ },
86
+ mailConfig: permissions.mailConfig,
87
+ calendarConfig: permissions.calendarConfig,
88
+ auth: context.admin
89
+ })
90
+ }
104
91
 
105
- /**
106
- * Get all data of a specified shared account in the specified context
107
- *
108
- * @param {*} context - The context where the shared account exists
109
- * @param {number} sharedAccountId - ID of the requested shared account
110
- * @returns {Promise<Object>} The shared account object
111
- */
112
- export async function getData (context, sharedAccountId) {
113
- return (await getClient()).getDataAsync({
114
- ctx: { id: context.id },
115
- sharedAccount: { id: sharedAccountId },
116
- auth: context.admin
117
- })
118
- }
92
+ /**
93
+ * List all shared accounts in the specified context
94
+ *
95
+ * @param {Object} context - The context object whose shared account will be listed
96
+ * @param {*} pattern - An optional search pattern for the contexts
97
+ * @returns {Promise<Object[]>} The list of shared account objects
98
+ */
99
+ async function list (context, pattern = '*') {
100
+ return (await getClient()).listAsync({
101
+ ctx: { id: context.id },
102
+ search_pattern: pattern,
103
+ auth: context.admin
104
+ })
105
+ }
119
106
 
120
- /**
121
- * Remove a specified shared account in the specified context
122
- *
123
- * @param {*} context - The context object where the account will be removed
124
- * @param {number} sharedAccountId - ID of the shared account to be removed
125
- * @returns {Promise<void>}
126
- */
127
- export async function remove (context, sharedAccountId) {
128
- return (await getClient()).deleteAsync({
129
- ctx: { id: context.id },
130
- sharedAccount: { id: sharedAccountId },
131
- auth: context.admin
132
- })
107
+ /**
108
+ * Get all data of a specified shared account in the specified context
109
+ *
110
+ * @param {*} context - The context where the shared account exists
111
+ * @param {number} sharedAccountId - ID of the requested shared account
112
+ * @returns {Promise<Object>} The shared account object
113
+ */
114
+ async function getData (context, sharedAccountId) {
115
+ return (await getClient()).getDataAsync({
116
+ ctx: { id: context.id },
117
+ sharedAccount: { id: sharedAccountId },
118
+ auth: context.admin
119
+ })
120
+ }
121
+
122
+ /**
123
+ * Remove a specified shared account in the specified context
124
+ *
125
+ * @param {*} context - The context object where the account will be removed
126
+ * @param {number} sharedAccountId - ID of the shared account to be removed
127
+ * @returns {Promise<void>}
128
+ */
129
+ async function remove (context, sharedAccountId) {
130
+ return (await getClient()).deleteAsync({
131
+ ctx: { id: context.id },
132
+ sharedAccount: { id: sharedAccountId },
133
+ auth: context.admin
134
+ })
135
+ }
136
+
137
+ return { create, convertUserToSharedAccount, createSharedAccountPermissions, list, getData, remove }
133
138
  }
139
+
140
+ // Classic module exports, bound to the env-configured default endpoint.
141
+ let defaultService
142
+ const service = () => (defaultService ??= createSharedAccountService(createClientAsync))
143
+
144
+ export const create = (...args) => service().create(...args)
145
+ export const convertUserToSharedAccount = (...args) => service().convertUserToSharedAccount(...args)
146
+ export const createSharedAccountPermissions = (...args) => service().createSharedAccountPermissions(...args)
147
+ export const list = (...args) => service().list(...args)
148
+ export const getData = (...args) => service().getData(...args)
149
+ export const remove = (...args) => service().remove(...args)
package/soap.js CHANGED
@@ -40,12 +40,9 @@ const RETRY_OPTIONS = {
40
40
  randomize: true // jitter to avoid thundering herd across parallel shards
41
41
  }
42
42
 
43
- for (const envFile of ['.env', '.env.defaults']) {
44
- try { process.loadEnvFile(envFile) } catch {}
45
- }
46
-
47
- // This flag enables debug output including timing information.
48
- const debug = process.env.DEBUG_SOAP === 'true'
43
+ // This flag enables debug output including timing information. Read lazily so
44
+ // importing the module has no environment-dependent behavior.
45
+ const debug = () => process.env.DEBUG_SOAP === 'true'
49
46
 
50
47
  /**
51
48
  * Walk one layer of common error-wrapper shapes:
@@ -91,44 +88,44 @@ function describeError (err) {
91
88
  'unknown'
92
89
  }
93
90
 
94
- // Handle specifically timeout-related unhandled promise rejections as fallback
95
- process.on('unhandledRejection', (reason, promise) => {
96
- const r = unwrapError(reason)
97
- if (r?.code === 'ECONNABORTED' && (r?.message?.includes('timeout') || r?.message?.includes('exceeded'))) {
98
- return
99
- }
100
-
101
- // Log other unhandled rejections as they indicate real problems
102
- console.error('Unhandled promise rejection:', describeError(reason))
103
- })
104
-
105
- // This URL is used to create the SOAP client.
106
- const provisioningUrl = String(process.env.PROVISIONING_URL).replace(/\/$/, '')
107
-
108
- // This user is used to authenticate against the provisioning API.
109
- const provisioningAuth = {
110
- auth: {
111
- login: process.env.E2E_ADMIN_USER,
112
- password: process.env.E2E_ADMIN_PW
113
- }
91
+ // Swallow specifically timeout-related unhandled promise rejections as
92
+ // fallback; log everything else, as those indicate real problems. Installed
93
+ // on demand (idempotent) — importing the module must not touch process-global
94
+ // state. The env-configured default client installs it for backwards
95
+ // compatibility with the CLI and test-runner consumers; factory clients from
96
+ // createClientFactory never do, so embedding applications keep their own
97
+ // rejection policy.
98
+ let rejectionFilterInstalled = false
99
+ function installTimeoutRejectionFilter () {
100
+ if (rejectionFilterInstalled) return
101
+ rejectionFilterInstalled = true
102
+ process.on('unhandledRejection', (reason, promise) => {
103
+ const r = unwrapError(reason)
104
+ if (r?.code === 'ECONNABORTED' && (r?.message?.includes('timeout') || r?.message?.includes('exceeded'))) {
105
+ return
106
+ }
107
+ console.error('Unhandled promise rejection:', describeError(reason))
108
+ })
114
109
  }
115
110
 
116
111
  /**
117
- * This code creates a PerformanceObserver instance which asynchronously observes
118
- * performance measurement events. It logs the name of the event (which is the name
119
- * of the SOAP method in this context) and the time it took to execute in milliseconds.
120
- *
121
- * This is used for performance tracking of the SOAP methods.
112
+ * Lazily create the PerformanceObserver that logs SOAP method timings when
113
+ * DEBUG_SOAP is enabled. One observer per process, created on first client
114
+ * construction rather than at import time.
122
115
  * @see https://nodejs.org/api/perf_hooks.html
123
- * @params {PerformanceObserverEntryList} items The list of performance entries.
124
116
  * @returns {void}
125
117
  */
126
- const obs = new PerformanceObserver((items) => {
127
- items.getEntries().forEach((entry) => {
128
- if (debug) console.log(`${entry.name} took ${Math.round(entry.duration)}ms`)
118
+ let debugObserverInstalled = false
119
+ function ensureDebugObserver () {
120
+ if (debugObserverInstalled || !debug()) return
121
+ debugObserverInstalled = true
122
+ const obs = new PerformanceObserver((items) => {
123
+ items.getEntries().forEach((entry) => {
124
+ console.log(`${entry.name} took ${Math.round(entry.duration)}ms`)
125
+ })
129
126
  })
130
- })
131
- obs.observe({ entryTypes: ['measure'] })
127
+ obs.observe({ entryTypes: ['measure'] })
128
+ }
132
129
 
133
130
  async function logSoapError (e) {
134
131
  console.error(describeError(e))
@@ -243,15 +240,169 @@ function handleFailedAttempt (context, label) {
243
240
  }
244
241
 
245
242
  /**
246
- * This function creates a SOAP client for the specified service type.
243
+ * @typedef {object} TransportOptions
244
+ * @property {string} url Base URL of the provisioning API (e.g. the core-mw
245
+ * admin Service), without the `/webservices` suffix.
246
+ * @property {{login: string, password: string}} auth Master admin credentials
247
+ * injected into every SOAP call unless the call passes its own `auth`. This
248
+ * one is a `Credentials` element and nothing else, so it is `login` only —
249
+ * see {@link AdminCredentials} for the per-call shape, which is wider.
250
+ */
251
+
252
+ /**
253
+ * @typedef {object} AdminCredentials
254
+ * @property {string} [login] The account name as the `auth` element calls it.
255
+ * @property {string} [name] The same account as the SOAP `admin_user` object
256
+ * calls it. Used when `login` is absent.
257
+ * @property {string} password
258
+ */
259
+
260
+ /**
261
+ * Read the account name out of an admin object.
262
+ *
263
+ * The SOAP `admin_user` object names the account `name`; only the `auth`
264
+ * element calls it `login`. Consumers hold the former and the transport needs
265
+ * the latter, and every one of them used to bridge that itself: the CLI
266
+ * (`bin/provision.js`), `resellerUserService` and appsuite-codeceptjs's
267
+ * `Context` constructor each added a `login` in their own way, and anyone who
268
+ * forgot authenticated with `login: undefined` and got a terminal
269
+ * "Authentication failed" from the middleware. Reading both shapes in one
270
+ * place removes the trap; `login` wins when both are present, so the bridges
271
+ * that remain outside this package keep working unchanged.
272
+ *
273
+ * @param {AdminCredentials} [admin]
274
+ * @returns {string|undefined}
275
+ */
276
+ function adminLogin (admin) {
277
+ return admin?.login || admin?.name
278
+ }
279
+
280
+ /**
281
+ * The capability reads answer with a display string, not a data structure, so
282
+ * parsing belongs here rather than in every caller.
283
+ *
284
+ * `OXUserServicePortTypeImpl.getUserCapabilities` and its context twin sort the
285
+ * set into a `TreeSet` and join it with `", "`, comma and space, so a plain
286
+ * `split(',')` leaves a blank in front of every entry but the first and nothing
287
+ * ever compares equal to the desired state. Worse, an empty set is reported as
288
+ * a sentence:
289
+ *
290
+ * There are no capabilities set for user 3 in context 42
291
+ * There are no capabilities set for context 42
292
+ *
293
+ * Returned verbatim that becomes one capability named after the sentence, which
294
+ * a reconciler then tries to remove. A context that has just been created is
295
+ * exactly the case that hits it, so the empty answer is the common one.
296
+ *
297
+ * @param {string} [capabilities] The raw `return` of a capabilities call.
298
+ * @returns {string[]} The capability names, or `[]` when there are none.
299
+ */
300
+ function parseCapabilities (capabilities) {
301
+ if (typeof capabilities !== 'string') return []
302
+ if (/^There are no capabilities set for /.test(capabilities)) return []
303
+ return capabilities.split(',').map(c => c.trim()).filter(Boolean)
304
+ }
305
+
306
+ /**
307
+ * Create a per-endpoint SOAP client constructor. Multiple factories coexist in
308
+ * one process, each bound to its own endpoint and credentials — nothing is
309
+ * read from the environment and no process-global state is touched.
310
+ * @param {TransportOptions} options
311
+ * @returns {(type: string) => Promise<Object>} createClientAsync bound to the endpoint.
312
+ */
313
+ function createClientFactory ({ url, auth }) {
314
+ if (!url) throw new TypeError('createClientFactory: url is required')
315
+ // Deliberately narrower than the per-call `auth` below: this is the master
316
+ // admin, which has no `admin_user` counterpart, so no SOAP object names it
317
+ // `name`. Accepting one would only let a wrong object through construction
318
+ // and turn it into a terminal "Authentication failed" on the first call.
319
+ if (!auth?.login || !auth?.password) throw new TypeError('createClientFactory: auth {login, password} is required')
320
+ const baseUrl = String(url).replace(/\/$/, '')
321
+ const defaultAuth = { auth: { login: auth.login, password: auth.password } }
322
+ return (type) => createTypedClientAsync(type, baseUrl, defaultAuth)
323
+ }
324
+
325
+ /**
326
+ * Memoize one SOAP client per service type, but only once it resolves.
327
+ *
328
+ * A plain `promise ??= createClient(type)` caches a rejection for the lifetime
329
+ * of the process: if the WSDL fetch exhausts its retry schedule or hits a
330
+ * non-retryable fault, every later call re-throws that stale error without ever
331
+ * touching the network again. That is fatal for a long-running embedder, where
332
+ * one bad startup would poison the service until the process restarts. Clearing
333
+ * the slot on failure keeps the happy path a single shared client while letting
334
+ * the next call re-attempt construction.
335
+ *
336
+ * @param {(type: string) => Promise<Object>} createClient
337
+ * @param {string} type The name of the service type.
338
+ * @returns {() => Promise<Object>} Getter returning the memoized client.
339
+ */
340
+ function memoizeClient (createClient, type) {
341
+ let clientPromise
342
+ return () => (clientPromise ??= createClient(type).catch(e => {
343
+ clientPromise = undefined
344
+ throw e
345
+ }))
346
+ }
347
+
348
+ // The env-configured default transport behind the classic module exports.
349
+ // Built lazily on first use: loads .env files, installs the timeout-rejection
350
+ // filter (CLI/test-runner compatibility), and binds PROVISIONING_URL +
351
+ // E2E_ADMIN_USER/PW. Factory transports never take this path.
352
+ let defaultCreateClient
353
+ function getDefaultCreateClient () {
354
+ if (!defaultCreateClient) {
355
+ for (const envFile of ['.env', '.env.defaults']) {
356
+ try { process.loadEnvFile(envFile) } catch {}
357
+ }
358
+ installTimeoutRejectionFilter()
359
+ // Report the missing *environment variables*, not createClientFactory's
360
+ // parameter names: a caller of the classic module exports never touched
361
+ // that function and would otherwise get an error naming an API it has
362
+ // never heard of. Note this is stricter than before — calls that carry
363
+ // their own `auth` (every user/account/reseller method takes
364
+ // `context.admin`) used to work with the master credentials unset, and
365
+ // now fail here. See the CHANGELOG entry for 0.2.0.
366
+ const missing = ['PROVISIONING_URL', 'E2E_ADMIN_USER', 'E2E_ADMIN_PW'].filter(key => !process.env[key])
367
+ if (missing.length) {
368
+ throw new TypeError(
369
+ `@open-xchange/soap-client: missing ${missing.join(', ')}. The module exports bind to the ` +
370
+ 'environment; set these in .env, or use createProvisioningClient({ url, auth }) from ' +
371
+ "'@open-xchange/soap-client/client' to pass an endpoint and credentials explicitly."
372
+ )
373
+ }
374
+ defaultCreateClient = createClientFactory({
375
+ url: process.env.PROVISIONING_URL,
376
+ auth: { login: process.env.E2E_ADMIN_USER, password: process.env.E2E_ADMIN_PW }
377
+ })
378
+ }
379
+ return defaultCreateClient
380
+ }
381
+
382
+ /**
383
+ * This function creates a SOAP client for the specified service type, bound to
384
+ * the env-configured default endpoint (PROVISIONING_URL, E2E_ADMIN_USER/PW).
247
385
  * @param {string} type The name of the service type.
248
386
  * @returns {Promise<Object>} The SOAP client.
249
387
  */
250
388
  async function createClientAsync (type) {
389
+ return getDefaultCreateClient()(type)
390
+ }
391
+
392
+ /**
393
+ * Create a SOAP client for the specified service type against a specific
394
+ * endpoint with specific credentials.
395
+ * @param {string} type The name of the service type.
396
+ * @param {string} baseUrl Base URL without trailing slash.
397
+ * @param {{auth: {login: string, password: string}}} defaultAuth Auth option injected per call.
398
+ * @returns {Promise<Object>} The SOAP client.
399
+ */
400
+ async function createTypedClientAsync (type, baseUrl, defaultAuth) {
401
+ ensureDebugObserver()
251
402
  const startMark = `${type}-start`
252
403
  const endMark = `${type}-end`
253
404
  performance.mark(startMark)
254
- const endpoint = `${provisioningUrl}/webservices/${type}`
405
+ const endpoint = `${baseUrl}/webservices/${type}`
255
406
  const url = `${endpoint}/?wsdl`
256
407
  // The WSDL fetch itself can hit transient network errors (ECONNRESET,
257
408
  // ETIMEDOUT, ...). Without retry here, a single TLS hiccup during
@@ -284,11 +435,21 @@ async function createClientAsync (type) {
284
435
  const startMark = `${prop}-start`
285
436
  const endMark = `${prop}-end`
286
437
  performance.mark(startMark)
287
- const soapOptions = { ...provisioningAuth, ...options }
288
- if (soapOptions.auth) {
289
- // only send login and password instead of complete admin object.
290
- // this can fail because of ambiguous namespacing
291
- soapOptions.auth = { login: soapOptions.auth.login, password: soapOptions.auth.password }
438
+ // Pull `auth` out before merging. Spreading `options` wholesale made a
439
+ // present-but-undefined `auth` (every user/account/reseller method
440
+ // sends `auth: context.admin`, and a context without a resolved admin
441
+ // makes that undefined) overwrite the endpoint credentials, so the
442
+ // call went out unauthenticated even though valid ones were
443
+ // configured. An absent or undefined `auth` now falls back to the
444
+ // endpoint default; a real one still wins.
445
+ const { auth, ...rest } = options ?? {}
446
+ const soapOptions = { ...defaultAuth, ...rest }
447
+ if (auth) {
448
+ // Send login and password only, never the whole admin object:
449
+ // the extra fields can fail on ambiguous namespacing. `adminLogin`
450
+ // reads either name the account goes by, so callers may pass an
451
+ // `admin_user` object straight through (see AdminCredentials).
452
+ soapOptions.auth = { login: adminLogin(auth), password: auth.password }
292
453
  }
293
454
 
294
455
  try {
@@ -313,4 +474,4 @@ async function createClientAsync (type) {
313
474
  })
314
475
  }
315
476
 
316
- export { createClientAsync, logSoapError, describeError, shouldAbortRetry, wrapSoapFault, unwrapError, handleFailedAttempt }
477
+ export { createClientAsync, createClientFactory, adminLogin, parseCapabilities, memoizeClient, installTimeoutRejectionFilter, logSoapError, describeError, shouldAbortRetry, wrapSoapFault, unwrapError, handleFailedAttempt }