This commit is contained in:
2026-06-25 18:34:45 -05:00
parent da16e5ff56
commit 552b86c323
12 changed files with 439 additions and 125 deletions

View File

@@ -1,38 +1,49 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { load } from './setup.mjs';
import http from 'node:http';
import { load, makeUser } from './setup.mjs';
const {
buildVmListPath,
buildHostPayloadFromVm,
buildStandaloneHostCandidate,
createCredential,
createVCenterConnection,
filterSummaryVms,
getVCenterConnection,
getVCenterVmPowerState,
normalizeBaseUrl,
normalizeVCenterVm,
previewVCenterHosts,
settingsFromVCenterConnection,
vmMatchesFilter
} = await load('../services/vcenterService.js');
} = {
...(await load('../services/vcenterService.js')),
...(await load('../models/credentialModel.js')),
...(await load('../models/vcenterConnectionModel.js'))
};
const originalFetch = globalThis.fetch;
function soapResponse(body) {
function soapResponse(body, init = {}) {
return new Response(`<?xml version="1.0" encoding="UTF-8"?><soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"><soapenv:Body>${body}</soapenv:Body></soapenv:Envelope>`, {
status: 200,
headers: { 'content-type': 'text/xml' }
headers: { 'content-type': 'text/xml', ...(init.headers || {}) }
});
}
function installStandaloneEsxiSoapStub() {
globalThis.fetch = async (_url, options = {}) => {
function installStandaloneEsxiSoapStub({ sdk404 = false } = {}) {
const calls = [];
globalThis.fetch = async (url, options = {}) => {
calls.push({ url: String(url), method: options.method || 'GET', body: String(options.body || ''), headers: options.headers || {} });
const body = String(options.body || '');
if (options.method === 'GET') return new Response('<definitions>vimService</definitions>', { status: 200, headers: { 'content-type': 'text/xml' } });
if (sdk404 && String(url).endsWith('/sdk')) return new Response('not found', { status: 404, statusText: 'Not Found', headers: { 'content-type': 'text/plain' } });
if (body.includes('<RetrieveServiceContent')) {
return soapResponse(`<RetrieveServiceContentResponse xmlns="urn:vim25"><returnval><rootFolder type="Folder">ha-folder-root</rootFolder><propertyCollector type="PropertyCollector">ha-property-collector</propertyCollector><viewManager type="ViewManager">ViewManager</viewManager><sessionManager type="SessionManager">ha-sessionmgr</sessionManager></returnval></RetrieveServiceContentResponse>`);
}
if (body.includes('<Login')) {
return soapResponse(`<LoginResponse xmlns="urn:vim25"><returnval type="UserSession">session-1</returnval></LoginResponse>`);
return soapResponse(`<LoginResponse xmlns="urn:vim25"><returnval type="UserSession">session-1</returnval></LoginResponse>`, { headers: { 'set-cookie': 'vmware_soap_session=session-1; Path=/sdk; Secure' } });
}
if (body.includes('<CreateContainerView')) {
return soapResponse(`<CreateContainerViewResponse xmlns="urn:vim25"><returnval type="ContainerView">session[52b6]-vm-view</returnval></CreateContainerViewResponse>`);
@@ -42,15 +53,79 @@ function installStandaloneEsxiSoapStub() {
}
return soapResponse('');
};
return () => { globalThis.fetch = originalFetch; };
return { calls, restore: () => { globalThis.fetch = originalFetch; } };
}
function startStandaloneEsxiSoapServer() {
const requests = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
requests.push({ url: req.url, method: req.method, body, cookie: req.headers.cookie || '' });
res.setHeader('content-type', 'text/xml');
if (req.method === 'GET') {
res.end('<definitions>vimService</definitions>');
} else if (body.includes('<Login')) {
res.setHeader('set-cookie', 'vmware_soap_session=session-http; Path=/sdk');
res.end(`<?xml version="1.0"?><Envelope><Body><LoginResponse><returnval type="UserSession">session-http</returnval></LoginResponse></Body></Envelope>`);
} else if (body.includes('<RetrieveServiceContent')) {
res.end(`<?xml version="1.0"?><Envelope><Body><RetrieveServiceContentResponse><returnval><rootFolder type="Folder">ha-folder-root</rootFolder><propertyCollector type="PropertyCollector">ha-property-collector</propertyCollector><viewManager type="ViewManager">ViewManager</viewManager><sessionManager type="SessionManager">ha-sessionmgr</sessionManager></returnval></RetrieveServiceContentResponse></Body></Envelope>`);
} else if (body.includes('<CreateContainerView')) {
res.end(`<?xml version="1.0"?><Envelope><Body><CreateContainerViewResponse><returnval type="ContainerView">session-http-vm-view</returnval></CreateContainerViewResponse></Body></Envelope>`);
} else if (body.includes('<RetrievePropertiesEx')) {
res.end(`<?xml version="1.0"?><Envelope><Body><RetrievePropertiesExResponse><returnval><objects><obj type="VirtualMachine">vim.VirtualMachine:201</obj><propSet><name>name</name><val>HTTP-ESXI-VM01</val></propSet><propSet><name>guest.hostName</name><val>http-esxi-vm01.lab.local</val></propSet><propSet><name>guest.ipAddress</name><val>10.42.9.21</val></propSet><propSet><name>guest.guestFullName</name><val>Microsoft Windows Server 2022</val></propSet><propSet><name>runtime.powerState</name><val>poweredOn</val></propSet></objects></returnval></RetrievePropertiesExResponse></Body></Envelope>`);
} else {
res.end(`<?xml version="1.0"?><Envelope><Body /></Envelope>`);
}
});
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
baseUrl: `http://127.0.0.1:${server.address().port}`,
requests,
close: () => new Promise((done) => server.close(done))
});
});
});
}
test('normalizeBaseUrl trims trailing slashes', () => {
assert.equal(normalizeBaseUrl('https://vcenter.lab.local///'), 'https://vcenter.lab.local');
});
test('VMware connections resolve API credentials from Credential Vault', () => {
const owner = makeUser({ email: 'vmware-vault@test.local' });
const credential = createCredential({
name: 'vCenter API',
kind: 'username_password',
username: 'administrator@vsphere.local',
secret: 'vault-secret',
visibility: 'personal'
}, owner);
const connection = createVCenterConnection({
name: 'Vault Backed vCenter',
targetType: 'vcenter',
baseUrl: 'https://vcenter.lab.local',
credentialId: credential.id,
apiMode: 'auto',
requestTimeoutMs: 20000,
allowUntrustedTls: true,
enabled: true,
visibility: 'personal'
}, owner);
const secretConnection = getVCenterConnection(connection.id, owner, true);
assert.equal(connection.credentialId, credential.id);
assert.equal(connection.credentialName, 'vCenter API');
assert.equal(connection.username, 'administrator@vsphere.local');
assert.equal(secretConnection.username, 'administrator@vsphere.local');
assert.equal(secretConnection.password, 'vault-secret');
});
test('standalone VMware host connections use the Web Services profile', async () => {
const restoreFetch = installStandaloneEsxiSoapStub();
const { calls, restore } = installStandaloneEsxiSoapStub();
const connection = {
id: 'vc_host',
name: 'ESXi 01',
@@ -68,9 +143,11 @@ test('standalone VMware host connections use the Web Services profile', async ()
assert.equal(settings.baseUrl, 'https://esxi01.lab.local/sdk');
const preview = await previewVCenterHosts({ connection, filter: { field: 'hostname', operator: 'contains', value: 'ENG-ENT' } });
restoreFetch();
restore();
assert.equal(preview.count, 1);
assert.ok(calls.some((call) => call.url.endsWith('/sdk/vimService.wsdl')));
assert.ok(calls.some((call) => call.headers.Cookie?.includes('vmware_soap_session')));
assert.equal(preview.diagnostics.standaloneHost, true);
assert.equal(preview.candidates[0].source, 'standalone-vm');
assert.equal(preview.candidates[0].fqdn, 'eng-ent-app01.contoso.local');
@@ -79,7 +156,7 @@ test('standalone VMware host connections use the Web Services profile', async ()
});
test('standalone VMware VM candidates build managed host payloads', async () => {
const restoreFetch = installStandaloneEsxiSoapStub();
const { restore } = installStandaloneEsxiSoapStub();
const connection = {
id: 'vc_host',
name: 'ESXi 01',
@@ -98,7 +175,7 @@ test('standalone VMware VM candidates build managed host payloads', async () =>
visibility: 'shared'
});
const power = await getVCenterVmPowerState(connection, preview.candidates[0].id);
restoreFetch();
restore();
assert.equal(payload.name, 'ENG-ENT-APP01');
assert.equal(payload.address, 'eng-ent-app01.contoso.local');
@@ -112,6 +189,50 @@ test('standalone VMware VM candidates build managed host payloads', async () =>
assert.equal(power.state, 'POWERED_ON');
});
test('standalone VMware SOAP retries alternate endpoint paths after /sdk 404', async () => {
const { calls, restore } = installStandaloneEsxiSoapStub({ sdk404: true });
const connection = {
id: 'vc_host_retry',
name: 'ESXi Retry',
target_type: 'host',
base_url: 'https://esxi01.lab.local/sdk/vimService.wsdl',
username: 'root',
password: 'secret',
enabled: 1
};
const preview = await previewVCenterHosts({ connection, filter: { field: 'hostname', operator: 'contains', value: 'eng-ent' } });
restore();
assert.equal(preview.count, 1);
assert.ok(calls.some((call) => call.url === 'https://esxi01.lab.local/sdk'));
assert.ok(calls.some((call) => call.url === 'https://esxi01.lab.local/sdk/'));
assert.ok(preview.trace.some((entry) => String(entry.message || '').includes('Retrying VMware SOAP action RetrieveServiceContent')));
});
test('standalone VMware custom dispatcher path works when untrusted TLS is enabled', async () => {
const server = await startStandaloneEsxiSoapServer();
try {
const connection = {
id: 'vc_host_dispatcher',
name: 'ESXi Dispatcher',
target_type: 'host',
base_url: server.baseUrl,
username: 'root',
password: 'secret',
enabled: 1,
allow_untrusted_tls: 1
};
const preview = await previewVCenterHosts({ connection, filter: { field: 'hostname', operator: 'contains', value: 'HTTP-ESXI' } });
assert.equal(preview.count, 1);
assert.equal(preview.candidates[0].name, 'HTTP-ESXI-VM01');
assert.ok(server.requests.some((request) => request.url === '/sdk/vimService.wsdl'));
assert.ok(server.requests.some((request) => request.cookie.includes('vmware_soap_session')));
} finally {
await server.close();
}
});
test('standalone VMware host candidates build managed host payloads', () => {
const candidate = buildStandaloneHostCandidate(
{ id: 'vc_host', name: 'ESXi 01' },