Files
Xen-Orchestra-xen-orchestra…/@xen-orchestra/vmware-explorer/soap/VimClient.test.mjs
Florent BEAUCHAMP 3bf50c38ee fix(vmware-explorer): fixes omnibus (#10363)
This branch is a reliability overhaul of the ESXi/vSphere source side of V2V, plus a scope reduction of the package. The SOAP plumbing is rewritten:

    VimClient wraps @vates/node-vsphere-soap in a promise API with one emitter per call (no more cross-talk between concurrent calls, no listener leak) and a pTimeout;
    vim25 faults are now parsed (_parseFault.mjs) so callers get a real fault type instead of task execution failed;
    the library no longer throws from async callbacks (which killed xo-server) and its beforeExit logout hook is per-client instead of removeAllListeners.
    Request specs are built in explicit schema order (soap/specs.mjs) with xsi:type, and property values are normalized (soap/normalize.mjs) so search() and the new #retrieveProperty share one shape.
    search() now paginates, cancels the retrieval and destroys the container view.
    Datastore HTTP downloads get session-cookie reuse, retry with jittered backoff, a headers-only timeout, and range-honouring validation.
    Task waiting polls with backoff and per-operation timeouts (6 h for snapshot consolidation).
    nbdkit servers are memoized by a key that includes the export options, the readiness wait races the process death instead of sleeping 2 s
    the password file is 0600 and unlinked
    the thumbprint is read over TLS in-process instead of two openssl spawns.
    The old VHD-over-datastore readers (VhdEsxi*, Datastore*, openDeltaVmdkAsVhd) are deleted, hence the majo, rleaving the vddk/NBD path with a COWD grain-directory parser as data-map fallback.
    ~1.7k lines of unit tests are added.
2026-09-11 17:36:38 +02:00

224 lines
7.4 KiB
JavaScript

import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { EventEmitter, once } from 'node:events'
import { setImmediate as flush } from 'node:timers/promises'
import { VimClient } from './VimClient.mjs'
class FakeSoapClient extends EventEmitter {
constructor({ status = 'ready' } = {}) {
super()
this.status = status
this.sessionManager = 'SessionManager'
this.serviceContent = { propertyCollector: 'propertyCollector' }
this.calls = []
}
runCommand(command, args) {
const emitter = new EventEmitter()
this.calls.push({ command, args, emitter })
return emitter
}
get lastCall() {
return this.calls[this.calls.length - 1]
}
}
const makeClient = (options = {}) => {
const client = new FakeSoapClient(options)
return { client, vimClient: new VimClient('host', 'user', 'password', true, { ...options, client }) }
}
describe('VimClient', function () {
describe('connect', function () {
it('resolves immediately when the client is already connected', async function () {
const { vimClient } = makeClient()
await vimClient.connect()
})
it('waits for the ready event', async function () {
const { client, vimClient } = makeClient({ status: 'connecting' })
const promise = vimClient.connect()
client.emit('ready')
await promise
})
it('rejects with the connection error', async function () {
const { client, vimClient } = makeClient({ status: 'connecting' })
const error = new Error('login failed')
const promise = vimClient.connect()
client.emit('error', error)
await assert.rejects(promise, /login failed/)
})
it('does not memoize a failed connection', async function () {
const { client, vimClient } = makeClient({ status: 'connecting' })
const first = vimClient.connect()
client.emit('error', new Error('login failed'))
await assert.rejects(first, /login failed/)
// the client is disconnected, connecting again must trigger a new attempt
client.status = 'disconnected'
const connecting = once(client, 'connect')
const second = vimClient.connect()
await connecting
client.status = 'ready'
client.emit('ready')
await second
assert.notEqual(first, second)
})
it('is memoized', async function () {
const { client, vimClient } = makeClient()
assert.equal(vimClient.connect(), vimClient.connect())
// no listener is left on the client, besides the one forwarding errors
assert.equal(client.listenerCount('ready'), 0)
})
})
describe('call', function () {
it('resolves with the result of the call', async function () {
const { client, vimClient } = makeClient()
const promise = vimClient.call('PowerOffVM_Task', { _this: 'vm-1' })
await flush() // let the call go through the awaited connection
const { command, args, emitter } = client.lastCall
assert.equal(command, 'PowerOffVM_Task')
assert.deepEqual(args, { _this: 'vm-1' })
emitter.emit('result', { returnval: { $value: 'task-1' } })
assert.deepEqual(await promise, { returnval: { $value: 'task-1' } })
})
it('rejects with the error of its own call', async function () {
const { client, vimClient } = makeClient()
const promise = vimClient.call('Whatever')
await flush()
client.lastCall.emitter.emit('error', new Error('InvalidDeviceSpec'))
await assert.rejects(promise, /InvalidDeviceSpec/)
})
it('is not disturbed by the failure of a concurrent call', async function () {
const { client, vimClient } = makeClient()
const failing = vimClient.call('Failing')
const succeeding = vimClient.call('Succeeding')
await flush()
const [first, second] = client.calls
first.emitter.emit('error', new Error('nope'))
second.emitter.emit('result', 'ok')
await assert.rejects(failing, /nope/)
assert.equal(await succeeding, 'ok')
})
it('does not leak listeners', async function () {
const { client, vimClient } = makeClient()
for (let i = 0; i < 20; i++) {
const promise = vimClient.call(`Call${i}`)
await flush()
const { emitter } = client.lastCall
if (i % 2 === 0) {
emitter.emit('result', i)
assert.equal(await promise, i)
} else {
emitter.emit('error', new Error(`error ${i}`))
await assert.rejects(promise)
}
assert.equal(emitter.listenerCount('result'), 0)
assert.equal(emitter.listenerCount('error'), 0)
}
// the only listener on the client is the one forwarding its errors
assert.equal(client.listenerCount('error'), 1)
assert.equal(client.listenerCount('result'), 0)
})
it('rejects when the server does not answer', async function () {
const { vimClient } = makeClient()
await assert.rejects(vimClient.call('Silent', undefined, { timeout: 1 }), { name: 'TimeoutError' })
})
})
describe('close', function () {
it('logs out', async function () {
const { client, vimClient } = makeClient()
const promise = vimClient.close()
await flush()
assert.equal(client.lastCall.command, 'Logout')
assert.deepEqual(client.lastCall.args, { _this: 'SessionManager' })
client.lastCall.emitter.emit('result', {})
await promise
})
it('does nothing when not connected', async function () {
const { client, vimClient } = makeClient({ status: 'disconnected' })
await vimClient.close()
assert.equal(client.calls.length, 0)
})
it('waits for a login in flight before logging out', async function () {
const { client, vimClient } = makeClient({ status: 'connecting' })
const connecting = vimClient.connect()
// this used to return early on a status which was not 'ready' yet: the login then completed
// afterwards and its session lingered on the server until it expired
const closing = vimClient.close()
await flush()
assert.deepEqual(client.calls, [])
client.status = 'ready'
client.emit('ready')
await connecting
await flush()
assert.equal(client.lastCall.command, 'Logout')
client.lastCall.emitter.emit('result', {})
await closing
})
it('does not log out when the login it waited for failed', async function () {
const { client, vimClient } = makeClient({ status: 'connecting' })
const connecting = vimClient.connect()
const closing = vimClient.close()
client.emit('error', new Error('login failed'))
await assert.rejects(connecting, /login failed/)
// the failure belongs to the caller of `connect`, closing has nothing to report
await closing
assert.deepEqual(client.calls, [])
})
it('does not throw when the logout fails', async function () {
const { client, vimClient } = makeClient()
const promise = vimClient.close()
await flush()
client.lastCall.emitter.emit('error', new Error('session already gone'))
await promise
})
})
it('forwards the errors raised once connected', async function () {
const client = new FakeSoapClient()
const errors = []
// eslint-disable-next-line no-new
new VimClient('host', 'user', 'password', true, { client, onError: error => errors.push(error) })
const error = new Error('session expired')
client.emit('error', error)
assert.deepEqual(errors, [error])
})
})