@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.
- package/CHANGELOG.md +41 -1
- package/README.md +23 -0
- package/client.js +72 -0
- package/package.json +18 -7
- package/services/common/context.js +156 -130
- package/services/common/user.js +207 -98
- package/services/common/util.js +23 -9
- package/services/reseller/oxaas.js +23 -11
- package/services/reseller/resellerContext.js +45 -29
- package/services/reseller/resellerUser.js +81 -63
- package/services/secondaryAccount.js +61 -46
- package/services/sharedAccount.js +120 -104
- package/soap.js +206 -45
- package/test/soap.test.js +0 -268
- package/vitest.config.js +0 -8
package/test/soap.test.js
DELETED
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @copyright Copyright (c) Open-Xchange GmbH, Germany <info@open-xchange.com>
|
|
3
|
-
* @license AGPL-3.0
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
// soap.js reads PROVISIONING_URL at module load to build the SOAP endpoint URL.
|
|
7
|
-
// We don't exercise that path here; set a stub so the import doesn't crash.
|
|
8
|
-
process.env.PROVISIONING_URL = 'http://soap.test/'
|
|
9
|
-
|
|
10
|
-
const { describeError, shouldAbortRetry, wrapSoapFault, handleFailedAttempt } = await import('../soap.js')
|
|
11
|
-
|
|
12
|
-
// Build a SOAP fault object matching the real shape (from soap@1.9.x).
|
|
13
|
-
function soapFault (faultstring, detail = {}) {
|
|
14
|
-
return {
|
|
15
|
-
root: { Envelope: { Body: { Fault: { faultstring, faultcode: 'soap:Server', detail } } } },
|
|
16
|
-
response: { status: 500, statusText: 'Internal Server Error' },
|
|
17
|
-
body: '<soap:Envelope>...</soap:Envelope>'
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// Build the wrapped shape that crashed the retry log: { error: <Error w/ SOAP fault props> }
|
|
22
|
-
function wrappedFault (faultstring, detail = {}) {
|
|
23
|
-
const err = new Error(`soap:Server: ${faultstring}`)
|
|
24
|
-
Object.assign(err, soapFault(faultstring, detail))
|
|
25
|
-
return { error: err }
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
describe('describeError', () => {
|
|
29
|
-
it('returns the message of a plain Error', () => {
|
|
30
|
-
expect(describeError(new Error('boom'))).toBe('boom')
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
it('unwraps { originalError: <Error> } and returns the inner message', () => {
|
|
34
|
-
const inner = new Error('inner-msg')
|
|
35
|
-
expect(describeError({ originalError: inner })).toBe('inner-msg')
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('unwraps { error: <Error> } (the shape that produced "(undefined)" in retry logs)', () => {
|
|
39
|
-
const inner = new Error('inner-msg')
|
|
40
|
-
expect(describeError({ error: inner })).toBe('inner-msg')
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('falls back to the SOAP faultstring when the wrapped Error has none', () => {
|
|
44
|
-
// Real-world: soap@1.9.x sometimes throws an Error whose .message lives
|
|
45
|
-
// *inside* the SOAP fault rather than on the Error itself. Verify the
|
|
46
|
-
// faultstring path kicks in.
|
|
47
|
-
const fault = soapFault('Could not acquire claim for context 2572225')
|
|
48
|
-
// strip the message so the .root.Envelope... path is the only signal
|
|
49
|
-
const e = Object.assign(Object.create(Error.prototype), fault)
|
|
50
|
-
expect(describeError(e)).toBe('Could not acquire claim for context 2572225')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('falls back to the SOAP faultstring through one level of { error: ... } wrapping', () => {
|
|
54
|
-
const wrapped = wrappedFault('Context 99 already exists')
|
|
55
|
-
// Stripping the inner message forces the faultstring fallback.
|
|
56
|
-
delete wrapped.error.message
|
|
57
|
-
expect(describeError(wrapped)).toBe('Context 99 already exists')
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('returns the error code for timeout-shaped errors (no message, no fault)', () => {
|
|
61
|
-
const e = { code: 'ETIMEDOUT' }
|
|
62
|
-
expect(describeError(e)).toBe('ETIMEDOUT')
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('returns a stringified preview if nothing else matches', () => {
|
|
66
|
-
const e = { weird: 'shape', no: 'normal fields' }
|
|
67
|
-
const out = describeError(e)
|
|
68
|
-
expect(out).toMatch(/weird/)
|
|
69
|
-
expect(out.length).toBeLessThanOrEqual(200)
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('returns "unknown" rather than the literal string "undefined" for empty input', () => {
|
|
73
|
-
expect(describeError(undefined)).toBe('unknown')
|
|
74
|
-
expect(describeError(null)).toBe('unknown')
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
it('handles strings thrown directly', () => {
|
|
78
|
-
expect(describeError('plain string error')).toBe('plain string error')
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
it('unwraps { original_error: <Error> } (snake_case from PropagatedError chains)', () => {
|
|
82
|
-
const inner = new Error('inner-msg')
|
|
83
|
-
expect(describeError({ original_error: inner })).toBe('inner-msg')
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
it('falls back to the SOAP faultstring through one level of { original_error: ... } wrapping', () => {
|
|
87
|
-
const inner = Object.assign(Object.create(Error.prototype), soapFault('Context 7 already exists'))
|
|
88
|
-
expect(describeError({ original_error: inner })).toBe('Context 7 already exists')
|
|
89
|
-
})
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
describe('shouldAbortRetry', () => {
|
|
93
|
-
it('does NOT abort on ETIMEDOUT / ECONNRESET / ECONNABORTED — these are retryable', () => {
|
|
94
|
-
expect(shouldAbortRetry({ code: 'ETIMEDOUT' })).toBe(false)
|
|
95
|
-
expect(shouldAbortRetry({ code: 'ECONNRESET' })).toBe(false)
|
|
96
|
-
expect(shouldAbortRetry({ code: 'ECONNABORTED' })).toBe(false)
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
it('does NOT abort on transient SOAP faults (e.g. context-claim conflict) — they retry until the lock frees', () => {
|
|
100
|
-
// This is the actual fault from the user's report — we *want* the retry
|
|
101
|
-
// loop to keep trying until the conflicting writer commits.
|
|
102
|
-
const e = soapFault('Could not acquire claim for context 2572225 due to another conflicting provisioning operation. Please try again later.')
|
|
103
|
-
expect(shouldAbortRetry(e)).toBe(false)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
it('aborts on "Context N already exists" — re-running will keep failing', () => {
|
|
107
|
-
const e = soapFault('Context 4242 already exists')
|
|
108
|
-
expect(shouldAbortRetry(e)).toBe(true)
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
it('aborts on "Authentication failed"', () => {
|
|
112
|
-
const e = soapFault('Authentication failed')
|
|
113
|
-
expect(shouldAbortRetry(e)).toBe(true)
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
it('aborts on blocked exceptions in the fault detail (e.g. ContextExistsException)', () => {
|
|
117
|
-
const e = soapFault('some message', { ContextExistsException: {} })
|
|
118
|
-
expect(shouldAbortRetry(e)).toBe(true)
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('looks through one level of { error: ... } wrapping when probing the fault', () => {
|
|
122
|
-
const wrapped = wrappedFault('Context 4242 already exists')
|
|
123
|
-
expect(shouldAbortRetry(wrapped)).toBe(true)
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
it('looks through { originalError: ... } wrapping too', () => {
|
|
127
|
-
const inner = new Error('whatever')
|
|
128
|
-
Object.assign(inner, soapFault('Authentication failed'))
|
|
129
|
-
expect(shouldAbortRetry({ originalError: inner })).toBe(true)
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
it('looks through { original_error: ... } (snake_case) wrapping too', () => {
|
|
133
|
-
const inner = new Error('whatever')
|
|
134
|
-
Object.assign(inner, soapFault('Context 4242 already exists'))
|
|
135
|
-
expect(shouldAbortRetry({ original_error: inner })).toBe(true)
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
it('does NOT abort on completely unknown error structures — retry and let it sort itself out', () => {
|
|
139
|
-
expect(shouldAbortRetry({ random: 'object' })).toBe(false)
|
|
140
|
-
expect(shouldAbortRetry(undefined)).toBe(false)
|
|
141
|
-
expect(shouldAbortRetry(null)).toBe(false)
|
|
142
|
-
})
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
describe('wrapSoapFault', () => {
|
|
146
|
-
it('returns the input unchanged when no SOAP fault is present', () => {
|
|
147
|
-
const e = new Error('plain timeout')
|
|
148
|
-
expect(wrapSoapFault(e)).toBe(e)
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
it('returns the input unchanged for non-SOAP wrapped errors (e.g. axios timeout)', () => {
|
|
152
|
-
const e = Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })
|
|
153
|
-
expect(wrapSoapFault({ error: e })).toEqual({ error: e })
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
it('returns a NEW Error with the fault structure preserved on it', () => {
|
|
157
|
-
// Real-world: the soap library hands us an Error whose .root/.response/.body
|
|
158
|
-
// carry the structured fault. The proxy catch used to throw a plain new
|
|
159
|
-
// Error("SOAP Fault: …"), which DELETED the structured fields and broke
|
|
160
|
-
// downstream consumers that pattern-match on err.faultstring.
|
|
161
|
-
const inner = Object.assign(new Error('original'), soapFault('Context 42 already exists', { ContextExistsException: {} }))
|
|
162
|
-
const out = wrapSoapFault(inner)
|
|
163
|
-
expect(out).not.toBe(inner) // it's a new Error so the message can be friendly
|
|
164
|
-
expect(out).toBeInstanceOf(Error)
|
|
165
|
-
expect(out.message).toBe('SOAP Fault: Context 42 already exists')
|
|
166
|
-
expect(out.faultstring).toBe('Context 42 already exists')
|
|
167
|
-
expect(out.faultcode).toBe('soap:Server')
|
|
168
|
-
expect(out.detail).toEqual({ ContextExistsException: {} })
|
|
169
|
-
expect(out.root).toBe(inner.root)
|
|
170
|
-
expect(out.response).toBe(inner.response)
|
|
171
|
-
expect(out.body).toBe(inner.body)
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
it('unwraps { error: <Error w/ fault> } before extracting the fault', () => {
|
|
175
|
-
const inner = Object.assign(new Error('original'), soapFault('Authentication failed'))
|
|
176
|
-
const out = wrapSoapFault({ error: inner })
|
|
177
|
-
expect(out.faultstring).toBe('Authentication failed')
|
|
178
|
-
expect(out.message).toBe('SOAP Fault: Authentication failed')
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
it('unwraps { original_error: <Error w/ fault> } (snake_case) before extracting the fault', () => {
|
|
182
|
-
const inner = Object.assign(new Error('original'), soapFault('No such user'))
|
|
183
|
-
const out = wrapSoapFault({ original_error: inner })
|
|
184
|
-
expect(out.faultstring).toBe('No such user')
|
|
185
|
-
})
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
// Regression: the exact shape from the original incident report — a transient
|
|
189
|
-
// "Could not acquire claim" fault wrapped in soap@1.9.x's `{ error: <Error> }`
|
|
190
|
-
// envelope, with the StorageException nested in detail. Three concerns checked
|
|
191
|
-
// together so the next person debugging this flow can see the whole pipeline
|
|
192
|
-
// pass against the real symptom in one place.
|
|
193
|
-
describe('real-world: transient "Could not acquire claim" fault wrapped in { error: ... }', () => {
|
|
194
|
-
const faultstring = 'Could not acquire claim for context 2572225 due to another conflicting provisioning operation. Please try again later.; exceptionId -1132283668-9930'
|
|
195
|
-
function realWorldThrownValue () {
|
|
196
|
-
const err = new Error(`soap:Server: ${faultstring}`)
|
|
197
|
-
Object.assign(err, soapFault(faultstring, { StorageException: { StorageException: null } }))
|
|
198
|
-
return { error: err }
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
it('describeError produces a useful retry-log line (not "(undefined)")', () => {
|
|
202
|
-
const out = describeError(realWorldThrownValue())
|
|
203
|
-
expect(out).toContain('Could not acquire claim for context 2572225')
|
|
204
|
-
expect(out).not.toBe('undefined')
|
|
205
|
-
expect(out).not.toBe('unknown')
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
it('shouldAbortRetry returns false — this is transient, keep retrying', () => {
|
|
209
|
-
// StorageException is NOT in the blockedExceptions list, and the
|
|
210
|
-
// faultstring doesn't match any blockedFaultStrings regex, so the retry
|
|
211
|
-
// loop must keep going until the conflicting writer commits.
|
|
212
|
-
expect(shouldAbortRetry(realWorldThrownValue())).toBe(false)
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
it('wrapSoapFault preserves .faultstring, .detail.StorageException, .root, .response, .body', () => {
|
|
216
|
-
const out = wrapSoapFault(realWorldThrownValue())
|
|
217
|
-
expect(out).toBeInstanceOf(Error)
|
|
218
|
-
expect(out.message).toMatch(/^SOAP Fault: Could not acquire claim for context 2572225/)
|
|
219
|
-
expect(out.faultstring).toContain('Could not acquire claim for context 2572225')
|
|
220
|
-
expect(out.faultcode).toBe('soap:Server')
|
|
221
|
-
expect(out.detail).toEqual({ StorageException: { StorageException: null } })
|
|
222
|
-
expect(out.root.Envelope.Body.Fault.faultstring).toContain('Could not acquire')
|
|
223
|
-
expect(out.response.status).toBe(500)
|
|
224
|
-
expect(out.body).toMatch(/soap:Envelope/)
|
|
225
|
-
})
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
describe('handleFailedAttempt (p-retry v8 onFailedAttempt)', () => {
|
|
229
|
-
// p-retry v8 hands the callback a context object, not the error itself.
|
|
230
|
-
function attemptContext (faultstring, { retriesLeft = 3 } = {}) {
|
|
231
|
-
const error = new Error(`soap:Server: ${faultstring}`)
|
|
232
|
-
Object.assign(error, soapFault(faultstring))
|
|
233
|
-
return { error, attemptNumber: 1, retriesLeft, retriesConsumed: 0, retryDelay: 0 }
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
it('aborts a blocked fault with a STRING message — the original incident', () => {
|
|
237
|
-
// Regression: this is the "Mandatory fields in context not set: [id]"
|
|
238
|
-
// fault that aborts retry. Before the fix, `new AbortError(context)`
|
|
239
|
-
// stored the context object as `.message`, so downstream
|
|
240
|
-
// `error.message.includes(...)` threw "includes is not a function".
|
|
241
|
-
let thrown
|
|
242
|
-
try {
|
|
243
|
-
handleFailedAttempt(attemptContext('Mandatory fields in context not set: [id]'), 'create')
|
|
244
|
-
} catch (e) {
|
|
245
|
-
thrown = e
|
|
246
|
-
}
|
|
247
|
-
expect(thrown).toBeInstanceOf(Error)
|
|
248
|
-
expect(typeof thrown.message).toBe('string')
|
|
249
|
-
})
|
|
250
|
-
|
|
251
|
-
it('aborts with the inner Error so wrapSoapFault can still recover the fault', () => {
|
|
252
|
-
let thrown
|
|
253
|
-
try {
|
|
254
|
-
handleFailedAttempt(attemptContext('Mandatory fields in context not set: [id]'), 'create')
|
|
255
|
-
} catch (e) {
|
|
256
|
-
thrown = e
|
|
257
|
-
}
|
|
258
|
-
const recovered = wrapSoapFault(thrown)
|
|
259
|
-
expect(recovered.message).toContain('Mandatory fields in context not set: [id]')
|
|
260
|
-
expect(recovered.faultstring).toContain('Mandatory fields in context not set: [id]')
|
|
261
|
-
})
|
|
262
|
-
|
|
263
|
-
it('does not throw for a transient (non-blocked) fault — lets p-retry continue', () => {
|
|
264
|
-
expect(() =>
|
|
265
|
-
handleFailedAttempt(attemptContext('Could not acquire claim for context 2572225'), 'create')
|
|
266
|
-
).not.toThrow()
|
|
267
|
-
})
|
|
268
|
-
})
|