@open-xchange/soap-client 0.2.0 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.1] - 2026-09-11
8
+
9
+ ### Added
10
+
11
+ - `userService.getMultipleData(context, users)` and `userService.getData(context, user)`. `list` and `listAll` return users with only the id filled, so their results carry no `name`, `display_name` or `primaryEmail` and cannot be compared against desired state. A caller that treats a list result as observed state sees every user as missing and recreates it. `getMultipleData` hydrates a whole set in one round trip; only each entry's id is sent. Both take a user id or an object carrying one, matching `remove`, `getModuleAccess`, `exists` and `changeCapabilities` on the same service, and reject a reference naming no user rather than sending `{ id: undefined }` and getting back an opaque `InvalidDataException`. `getMultipleData` answers `[]` for an empty result, where the raw response would be `undefined` because the element is a `List<User>` that serializes to nothing, and makes no round trip at all for an empty set of ids. It is all or nothing: the operation declares `NoSuchUserException`, so one id disappearing between the `list` and the hydrate rejects the whole call, and a caller that must tolerate that has to fall back to `getData` per id itself.
12
+ - `userService.getUserCapabilities(context, user)` and `contextService.getContextCapabilities(id)`, returning the capability names as an array. Without them a caller can only re-apply capabilities blindly on every reconcile instead of diffing them. The middleware answers these two with a display string rather than a data structure, so the parsing sits here: it joins the set with `", "`, comma and space, and reports an empty set as the sentence `There are no capabilities set for ...`, which becomes `[]`. Handed back raw, that sentence would read as a single capability named after itself, and a freshly created context is the case that hits it.
13
+
14
+ ### Changed
15
+
16
+ - `resellerUserService` no longer forces `name` as the login. Where an admin object carries both a `login` and a `name` that differ, its calls now send `login`, matching every other service in the package. A reseller call on a context whose admin never resolved now falls back to the configured endpoint credentials as well, instead of throwing a `TypeError` on `context.admin.name`.
17
+
18
+ ### Fixed
19
+
20
+ - An admin object in the SOAP `admin_user` shape (`{ name, password }`) now authenticates instead of sending `login: undefined`. The transport reads `auth.login`, but the SOAP admin object names the account `name` and only the `auth` element calls it `login`, so a consumer passing its admin object straight through authenticated with no login and the middleware rejected it as a terminal authentication failure. The per-call `auth` accepts both shapes now, `login` winning when both are present. The endpoint credentials given to `createProvisioningClient` are unchanged: that element is only ever `Credentials` and the master admin has no `admin_user` counterpart, so it still requires `{ login, password }` and still throws a `TypeError` at construction without one.
21
+ - `resellerUserService` carried the same bug from the opposite side. It built `auth: { login: context.admin.name }` itself, so an admin already in the `{ login, password }` shape authenticated with no login there. All five of its authenticated methods now send `auth: context.admin` like every other service and go through the same mapping.
22
+
7
23
  ## [0.2.0] - 2026-09-08
8
24
 
9
25
  ### Added
@@ -141,7 +157,8 @@ All notable changes to this project will be documented in this file.
141
157
 
142
158
  - Initial release: extract SOAP client into its own library
143
159
 
144
- [unreleased]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.2.0...main
160
+ [unreleased]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.2.1...main
161
+ [0.2.1]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.2.0...soap-client-0.2.1
145
162
  [0.2.0]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.6...soap-client-0.2.0
146
163
  [0.1.6]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.5...soap-client-0.1.6
147
164
  [0.1.5]: https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/compare/soap-client-0.1.4...soap-client-0.1.5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-xchange/soap-client",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "SOAP client for OX App Suite",
5
5
  "type": "module",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "devDependencies": {
39
39
  "@open-xchange/lint": "^0.3.1",
40
- "vitest": "^4.1.7"
40
+ "vitest": "^5.0.0"
41
41
  },
42
42
  "scripts": {
43
43
  "lint": "eslint .",
@@ -18,7 +18,7 @@
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, memoizeClient, logSoapError } from '../../soap.js'
21
+ import { createClientAsync, memoizeClient, logSoapError, parseCapabilities } from '../../soap.js'
22
22
 
23
23
  /**
24
24
  * Build a context service bound to a SOAP client constructor.
@@ -149,6 +149,18 @@ export function createContextService (createClient, { mxDomain } = {}) {
149
149
  return (await getClient()).getDataAsync({ ctx: { id } })
150
150
  }
151
151
 
152
+ /**
153
+ * The capabilities currently in effect for a context. The middleware answers
154
+ * with a display string (see `parseCapabilities`), so this returns the names
155
+ * it lists. Without it a caller can only re-apply capabilities blindly on
156
+ * every reconcile, never diff them.
157
+ * @param {number} id The context id.
158
+ * @returns {Promise<string[]>} Capability names, `[]` when there are none.
159
+ */
160
+ async function getContextCapabilities (id) {
161
+ return parseCapabilities(await (await getClient()).getContextCapabilitiesAsync({ ctx: { id } }))
162
+ }
163
+
152
164
  /**
153
165
  * This function changes the context with the specified ID.
154
166
  * @param {Object} ctx The context to change.
@@ -158,7 +170,7 @@ export function createContextService (createClient, { mxDomain } = {}) {
158
170
  return (await getClient()).changeAsync({ ctx })
159
171
  }
160
172
 
161
- return { getDefault, remove, create, changeModuleAccessByName, changeCapabilities, getModuleAccess, changeModuleAccess, list, get, change }
173
+ return { getDefault, remove, create, changeModuleAccessByName, changeCapabilities, getModuleAccess, changeModuleAccess, list, get, change, getContextCapabilities }
162
174
  }
163
175
 
164
176
  // Classic module exports, bound to the env-configured default endpoint.
@@ -175,3 +187,4 @@ export const changeModuleAccess = (...args) => service().changeModuleAccess(...a
175
187
  export const list = (...args) => service().list(...args)
176
188
  export const get = (...args) => service().get(...args)
177
189
  export const change = (...args) => service().change(...args)
190
+ export const getContextCapabilities = (...args) => service().getContextCapabilities(...args)
@@ -18,13 +18,33 @@
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, memoizeClient, logSoapError } from '../../soap.js'
21
+ import { createClientAsync, memoizeClient, logSoapError, parseCapabilities } from '../../soap.js'
22
22
 
23
23
  /**
24
24
  * Build a user service bound to a SOAP client constructor.
25
25
  * @param {(type: string) => Promise<Object>} createClient
26
26
  * @returns {Object} The user service.
27
27
  */
28
+ /**
29
+ * Build the `user` element of a request from whatever the caller holds.
30
+ *
31
+ * Every older method on this service takes a plain id (`remove(context, 3)`,
32
+ * `getModuleAccess`, `exists`, `changeCapabilities`), so a caller following the
33
+ * convention passes one to the newer ones too. Destructuring a number yields
34
+ * `{ id: undefined }`, and because `user` is `nillable minOccurs="0"` the
35
+ * request stays schema-valid: the mistake comes back as an opaque
36
+ * InvalidDataException from the middleware instead of failing here. Accept both
37
+ * forms, and refuse a reference that names no user at all.
38
+ *
39
+ * @param {{id: number}|number} user An id, or an object carrying one.
40
+ * @returns {{id: number}} The `user` element to send.
41
+ */
42
+ function userRef (user) {
43
+ const id = user !== null && typeof user === 'object' ? user.id : user
44
+ if (id === undefined || id === null) throw new TypeError('userRef: a user id is required')
45
+ return { id }
46
+ }
47
+
28
48
  export function createUserService (createClient) {
29
49
  const getClient = memoizeClient(createClient, 'OXUserService')
30
50
 
@@ -125,6 +145,71 @@ export function createUserService (createClient) {
125
145
  })
126
146
  }
127
147
 
148
+ /**
149
+ * Hydrate users. `list` and `listAll` fill only the id, so their results
150
+ * carry no name, display_name or email and cannot be diffed against desired
151
+ * state; `getMultipleData` returns the full records for a set of ids in one
152
+ * call. Only the id of each entry is sent, whatever else the caller holds.
153
+ *
154
+ * The response element is a `List<User>`, so an empty result serializes to
155
+ * zero `<return>` elements and node-soap never creates the key. That would
156
+ * surface as `undefined` rather than an empty list and throw in the caller's
157
+ * `.map`, so it is normalised here.
158
+ *
159
+ * All or nothing: the operation declares `NoSuchUserException`, so if any one
160
+ * id disappeared between the `list` that produced it and this call, the whole
161
+ * request rejects and no user is hydrated. A caller that must tolerate a
162
+ * concurrent deletion has to fall back to `getData` per id itself; this
163
+ * method will not return partial results.
164
+ *
165
+ * @param {Object} context `{ id, admin }`
166
+ * @param {Array<{id: number}|number>} users The users to hydrate.
167
+ * @returns {Promise<Array<Object>>} The full user records, `[]` for none.
168
+ */
169
+ async function getMultipleData (context, users = []) {
170
+ const refs = users.map(userRef)
171
+ if (refs.length === 0) return []
172
+ return (await (await getClient()).getMultipleDataAsync({
173
+ ctx: { id: context.id },
174
+ users: refs,
175
+ auth: context.admin
176
+ })) ?? []
177
+ }
178
+
179
+ /**
180
+ * Hydrate one user. Same reason as getMultipleData; prefer that one for a
181
+ * set, since it is a single round trip.
182
+ * @param {Object} context `{ id, admin }`
183
+ * @param {{id: number}|number} user An id, or an object carrying one.
184
+ * @returns {Promise<Object>} The full user record.
185
+ */
186
+ async function getData (context, user) {
187
+ const ref = userRef(user)
188
+ return (await getClient()).getDataAsync({
189
+ ctx: { id: context.id },
190
+ user: ref,
191
+ auth: context.admin
192
+ })
193
+ }
194
+
195
+ /**
196
+ * The capabilities currently in effect for a user. The middleware answers
197
+ * with a display string (see `parseCapabilities`), so this returns the names
198
+ * it lists. Without it a caller can only re-apply capabilities blindly on
199
+ * every reconcile, never diff them.
200
+ * @param {Object} context `{ id, admin }`
201
+ * @param {{id: number}|number} user An id, or an object carrying one.
202
+ * @returns {Promise<string[]>} Capability names, `[]` when there are none.
203
+ */
204
+ async function getUserCapabilities (context, user) {
205
+ const ref = userRef(user)
206
+ return parseCapabilities(await (await getClient()).getUserCapabilitiesAsync({
207
+ ctx: { id: context.id },
208
+ user: ref,
209
+ auth: context.admin
210
+ }))
211
+ }
212
+
128
213
  async function listAll (context, includeGuests = false, excludeUsers = []) {
129
214
  return (await getClient()).listAsync({
130
215
  ctx: { id: context.id },
@@ -134,7 +219,7 @@ export function createUserService (createClient) {
134
219
  })
135
220
  }
136
221
 
137
- return { remove, create, change, changeByModuleAccess, changeByModuleAccessName, getModuleAccess, changeCapabilities, exists, list, listAll }
222
+ return { remove, create, change, changeByModuleAccess, changeByModuleAccessName, getModuleAccess, changeCapabilities, exists, list, listAll, getData, getMultipleData, getUserCapabilities }
138
223
  }
139
224
 
140
225
  // Classic module exports, bound to the env-configured default endpoint.
@@ -153,3 +238,6 @@ export const changeCapabilities = (...args) => service().changeCapabilities(...a
153
238
  export const exists = (...args) => service().exists(...args)
154
239
  export const list = (...args) => service().list(...args)
155
240
  export const listAll = (...args) => service().listAll(...args)
241
+ export const getData = (...args) => service().getData(...args)
242
+ export const getMultipleData = (...args) => service().getMultipleData(...args)
243
+ export const getUserCapabilities = (...args) => service().getUserCapabilities(...args)
@@ -45,7 +45,7 @@ export function createResellerUserService (createClient) {
45
45
  return (await getClient()).changeAsync({
46
46
  ctx: { id: context.id },
47
47
  usrdata,
48
- auth: { login: context.admin.name, password: context.admin.password }
48
+ auth: context.admin
49
49
  })
50
50
  }
51
51
 
@@ -57,7 +57,7 @@ export function createResellerUserService (createClient) {
57
57
  return (await getClient()).getModuleAccessAsync({
58
58
  ctx: { id: context.id },
59
59
  user: { id: userId },
60
- auth: { login: context.admin.name, password: context.admin.password }
60
+ auth: context.admin
61
61
  })
62
62
  }
63
63
 
@@ -66,7 +66,7 @@ export function createResellerUserService (createClient) {
66
66
  ctx: { id: context.id },
67
67
  moduleAccess: Object.assign({}, currentAccess, moduleAccess),
68
68
  user: { id: userId },
69
- auth: { login: context.admin.name, password: context.admin.password }
69
+ auth: context.admin
70
70
  })
71
71
  }
72
72
 
@@ -75,7 +75,7 @@ export function createResellerUserService (createClient) {
75
75
  ctx: { id: context.id },
76
76
  access_combination_name: accessCombinationName,
77
77
  user: { id: userId },
78
- auth: { login: context.admin.name, password: context.admin.password }
78
+ auth: context.admin
79
79
  })
80
80
  }
81
81
 
@@ -84,7 +84,7 @@ export function createResellerUserService (createClient) {
84
84
  ctx: { id: context.id },
85
85
  usrdata,
86
86
  access_combination_name: 'all',
87
- auth: { login: context.admin.name, password: context.admin.password }
87
+ auth: context.admin
88
88
  })
89
89
  }
90
90
 
package/soap.js CHANGED
@@ -244,9 +244,65 @@ function handleFailedAttempt (context, label) {
244
244
  * @property {string} url Base URL of the provisioning API (e.g. the core-mw
245
245
  * admin Service), without the `/webservices` suffix.
246
246
  * @property {{login: string, password: string}} auth Master admin credentials
247
- * injected into every SOAP call unless the call passes its own `auth`.
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.
248
250
  */
249
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
+
250
306
  /**
251
307
  * Create a per-endpoint SOAP client constructor. Multiple factories coexist in
252
308
  * one process, each bound to its own endpoint and credentials — nothing is
@@ -256,6 +312,10 @@ function handleFailedAttempt (context, label) {
256
312
  */
257
313
  function createClientFactory ({ url, auth }) {
258
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.
259
319
  if (!auth?.login || !auth?.password) throw new TypeError('createClientFactory: auth {login, password} is required')
260
320
  const baseUrl = String(url).replace(/\/$/, '')
261
321
  const defaultAuth = { auth: { login: auth.login, password: auth.password } }
@@ -385,9 +445,11 @@ async function createTypedClientAsync (type, baseUrl, defaultAuth) {
385
445
  const { auth, ...rest } = options ?? {}
386
446
  const soapOptions = { ...defaultAuth, ...rest }
387
447
  if (auth) {
388
- // only send login and password instead of complete admin object.
389
- // this can fail because of ambiguous namespacing
390
- soapOptions.auth = { login: auth.login, password: auth.password }
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 }
391
453
  }
392
454
 
393
455
  try {
@@ -412,4 +474,4 @@ async function createTypedClientAsync (type, baseUrl, defaultAuth) {
412
474
  })
413
475
  }
414
476
 
415
- export { createClientAsync, createClientFactory, memoizeClient, installTimeoutRejectionFilter, logSoapError, describeError, shouldAbortRetry, wrapSoapFault, unwrapError, handleFailedAttempt }
477
+ export { createClientAsync, createClientFactory, adminLogin, parseCapabilities, memoizeClient, installTimeoutRejectionFilter, logSoapError, describeError, shouldAbortRetry, wrapSoapFault, unwrapError, handleFailedAttempt }