/** * The Proxmox client, exercised over a real socket — against the SAME handlers * the e2e rig serves (D8). * * `proxmox.ts` hand-rolls `node:https` across 1,169 lines, and its existing * 240-line test covers only the pure helpers: not one assertion reaches the * wire. So the auth header, the `{data}` envelope, the 401 path and every * non-2xx branch have gone unasserted — in the file that talks to the * hypervisor. * * ## Why a real server rather than `setupServer` * * D8 proposed MSW's in-process `setupServer` for exactly this. **It does not * work under Bun**, measured rather than assumed: `setupServer` intercepts * global `fetch`, but a `node:https.request` goes straight past it and fails * with `ECONNREFUSED` from Bun's own `node:_http_client`. `proxmox.ts` uses * `node:https` directly, so the interceptor never sees it. * * `@celilo/terraform-fake` already exposes the same handlers as a real HTTPS * server, so that is what these use. The D8 goal is met either way — ONE handler * set behind both the client's unit tests and the rig's simulator, so the two * cannot come to believe different things about what Proxmox returns — but it is * met over a socket instead of an interceptor. The cost is a port and a * self-signed certificate; the client already sets `rejectUnauthorized: false`, * as every real consumer does. */ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import https from 'node:https'; import type { AddressInfo } from 'node:net'; import { type ProxmoxFake, createProxmoxFake } from '@celilo/terraform-fake'; import { ProxmoxClient, type ProxmoxCredentials, buildProxmoxApiUrl, getNodeForVmid, listNodeStorage, testProxmoxConnection, } from './proxmox'; let fake: ProxmoxFake; let credentials: ProxmoxCredentials; let tls: { key: string; cert: string }; const TOKEN_ID = 'celilo@pve!e2e'; const TOKEN_SECRET = '00000000-0000-0000-0000-000000000000'; beforeAll(async () => { tls = await generateSelfSigned(); fake = createProxmoxFake({ tls, nodes: [{ name: 'pve1', cores: 8, memoryBytes: 16 * 1024 ** 3, diskBytes: 500 * 1024 ** 3 }], storages: [{ name: 'local-lvm', content: 'images,rootdir' }], }); const port = await fake.listen(0); credentials = { api_url: buildProxmoxApiUrl('127.0.0.1', port), api_token_id: TOKEN_ID, api_token_secret: TOKEN_SECRET, }; }); afterAll(async () => { await fake.close(); }); /** * Run one request against a throwaway server that answers however the test * needs, for the branches a well-behaved Proxmox never exercises. */ async function withStubServer( reply: (res: import('node:http').ServerResponse) => void, run: (credentials: ProxmoxCredentials) => Promise, ): Promise { const server = https.createServer(tls, (_req, res) => reply(res)); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address() as AddressInfo; try { return await run({ api_url: buildProxmoxApiUrl('127.0.0.1', port), api_token_id: TOKEN_ID, api_token_secret: TOKEN_SECRET, }); } finally { await new Promise((resolve) => server.close(() => resolve())); } } describe('the request the client actually sends', () => { test('carries the PVEAPIToken header Proxmox requires', async () => { // Never asserted before. Proxmox rejects the session outright if this is // malformed, and the client builds it by string concatenation. let seen: string | undefined; const server = https.createServer(tls, (req, res) => { seen = req.headers.authorization; res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ data: [] })); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address() as AddressInfo; await new ProxmoxClient({ ...credentials, api_url: buildProxmoxApiUrl('127.0.0.1', port), }).clusterResources(); await new Promise((resolve) => server.close(() => resolve())); expect(seen).toBe(`PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}`); }); test('unwraps the `{data}` envelope rather than handing back the whole body', async () => { const result = await listNodeStorage(credentials, 'pve1'); expect(result.success).toBe(true); expect(Array.isArray(result.success && result.data)).toBe(true); }); test('a query string is DROPPED — the constraint every caller works around', async () => { // `makeProxmoxRequest` passes `url.pathname` and never `url.search`, so a // filter silently does not arrive. `getNodeForVmid` documents this and // fetches the whole inventory instead. Pinned here so that adding `?type=vm` // somewhere and quietly getting unfiltered results is a test failure rather // than a puzzling runtime one. let seenUrl: string | undefined; const server = https.createServer(tls, (req, res) => { seenUrl = req.url; res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ data: [] })); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address() as AddressInfo; await getNodeForVmid({ ...credentials, api_url: buildProxmoxApiUrl('127.0.0.1', port) }, 1); await new Promise((resolve) => server.close(() => resolve())); expect(seenUrl).toBe('/api2/json/cluster/resources'); expect(seenUrl).not.toContain('?'); }); }); describe('a POST — the other half of the wire contract', () => { test('sends form-encoded parameters, not JSON', async () => { // Proxmox only accepts `application/x-www-form-urlencoded`; sending JSON // gets a 400 that names no field. Never asserted before. // // The stub answers BOTH paths, because `setGuestPower` first resolves the // node via `/cluster/resources` and only then issues the POST. let contentType: string | undefined; let postedTo: string | undefined; let body = ''; const server = https.createServer(tls, (req, res) => { if (req.method === 'GET') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ data: [{ vmid: 403, node: 'pve1', type: 'lxc' }] })); return; } contentType = req.headers['content-type']; postedTo = req.url; req.on('data', (c) => { body += c; }); req.on('end', () => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ data: 'UPID:pve1:00000001:0:66BF0000:vzshutdown:403:root@pam:' })); }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address() as AddressInfo; const result = await new ProxmoxClient({ ...credentials, api_url: buildProxmoxApiUrl('127.0.0.1', port), }).setGuestPower(403, 'lxc', 'shutdown'); await new Promise((resolve) => server.close(() => resolve())); expect(result.success).toBe(true); expect(contentType).toBe('application/x-www-form-urlencoded'); expect(body).not.toStartWith('{'); // `shutdown`, not `stop`: a pause is planned, so the guest is asked to go // quietly rather than having its power pulled. expect(postedTo).toBe('/api2/json/nodes/pve1/lxc/403/status/shutdown'); }, 30_000); }); describe('what the client does with a response it did not want', () => { test('401 says the credentials are wrong, not merely that something failed', async () => { const result = await withStubServer( (res) => { res.writeHead(401); res.end(); }, (creds) => new ProxmoxClient(creds).clusterResources(), ); expect(result.success).toBe(false); expect(result.success === false && result.message).toContain('Authentication failed'); }); test('a 500 reports the status, so a caller can tell it from a refusal', async () => { const result = await withStubServer( (res) => { res.writeHead(500); res.end('boom'); }, (creds) => new ProxmoxClient(creds).clusterResources(), ); expect(result.success).toBe(false); expect(result.success === false && result.message).toContain('500'); }); test('a non-JSON body is a parse failure, not a crash', async () => { // Proxmox behind a misconfigured proxy answers HTML. The client must still // return a result, because every caller branches on `.success`. const result = await withStubServer( (res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end('nope'); }, (creds) => new ProxmoxClient(creds).clusterResources(), ); expect(result.success).toBe(false); expect(result.success === false && result.message).toContain('parse'); }); test('an unreachable host resolves to a failure instead of rejecting', async () => { // The whole function is a `new Promise(resolve)` with no reject path, so a // throw here would surface as an unhandled rejection inside a CLI command. const result = await new ProxmoxClient({ ...credentials, // Reserved for documentation (RFC 5737) and never routable. api_url: buildProxmoxApiUrl('192.0.2.1', 9), }).clusterResources(); expect(result.success).toBe(false); }, 30_000); }); describe('against the real handler set', () => { test('listNodeStorage returns the storages the fake declares', async () => { const result = await listNodeStorage(credentials, 'pve1'); const names = result.success ? (result.data as Array<{ storage: string }>).map((s) => s.storage) : []; expect(names).toContain('local-lvm'); }); test('listClusterResources gets node rows AND guest rows from one unfiltered call', async () => { // Why the fake returns both when no `?type=` is given, even though the // Terraform provider needs the filtered form: this client cannot ask for a // filter (see the dropped-query test) and needs both kinds at once. fake.state.addGuest({ vmid: 401, node: 'pve1', kind: 'lxc', hostname: 'client-test', status: 'running', config: { cores: '2', memory: '1024' }, }); const result = await new ProxmoxClient(credentials).clusterResources(); const kinds = new Set(result.success ? result.data.map((r) => r.type) : []); expect(kinds.has('node')).toBe(true); expect(kinds.has('lxc')).toBe(true); fake.state.removeGuest(401); }); test('getNodeForVmid finds the node a guest lives on', async () => { fake.state.addGuest({ vmid: 402, node: 'pve1', kind: 'lxc', hostname: 'placed', status: 'running', config: {}, }); const result = await getNodeForVmid(credentials, 402); expect(result.success && result.data).toBe('pve1'); fake.state.removeGuest(402); }); test('getNodeForVmid returns null for a vmid that does not exist yet', async () => { // First deploy: the container has not been created. Distinct from an error, // and the callers rely on the difference. const result = await getNodeForVmid(credentials, 9999); expect(result.success).toBe(true); expect(result.success && result.data).toBeNull(); }); }); describe('testProxmoxConnection', () => { test('succeeds against a token with full permissions', async () => { const result = await testProxmoxConnection(credentials); expect(result.success).toBe(true); }); test('a 401 on the probe fails the connection test', async () => { const result = await withStubServer( (res) => { res.writeHead(401); res.end(); }, (creds) => testProxmoxConnection(creds), ); expect(result.success).toBe(false); }); }); /** A throwaway pair. Validity beyond "parses as a cert" is not the point. */ async function generateSelfSigned(): Promise<{ key: string; cert: string }> { const dir = `/tmp/proxmox-client-test-${process.pid}`; await Bun.$`mkdir -p ${dir}`.quiet(); await Bun.$`openssl req -x509 -newkey rsa:2048 -keyout ${dir}/key.pem -out ${dir}/cert.pem -days 1 -nodes -subj /CN=proxmox.test`.quiet(); return { key: await Bun.file(`${dir}/key.pem`).text(), cert: await Bun.file(`${dir}/cert.pem`).text(), }; }