import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; import { buildGatewayVersionInfo, buildVersionInfo } from '../../dist/server/version.js'; import { createVersionRoute } from '../../dist/server/http/routes/versionRoute.js'; import { HARBOR_VERSIONS, versionCompatibility } from '../../dist/shared/versions.js'; function response() { return { writeHead(status, headers) { this.status = status; this.headers = headers; }, end(payload) { this.rawPayload = payload; this.payload = JSON.parse(payload); }, }; } const controlVersionInfo = { apiVersion: 1, location: 'gateway', components: { gatewayBackend: '0.21.17' }, runtime: { singBox: '1.12.13' }, }; test('component versions enforce one Harbor major and one Gateway major.minor', () => { assert.deepEqual(versionCompatibility(HARBOR_VERSIONS), { compatible: true, major: true, gateway: true, }); assert.equal(versionCompatibility({ macClient: '1.9.4', gatewayClient: '1.3.1', gatewayBackend: '1.3.8', }).compatible, true); assert.equal(versionCompatibility({ macClient: '1.0.0', gatewayClient: '1.3.0', gatewayBackend: '1.4.0', }).compatible, false); }); test('gateway reports control backend and deployed dataplane separately', () => { const control = { apiVersion: 1, location: 'gateway', components: { gatewayBackend: '0.4.0' }, runtime: { singBox: '1.12.13' }, }; assert.deepEqual(buildGatewayVersionInfo(control, { gatewayBackendVersion: '0.3.0', singBoxVersion: '1.12.13', }), { apiVersion: 1, location: 'gateway', components: { gatewayBackend: '0.4.0' }, runtime: { dataplaneVersion: '0.3.0', singBox: '1.12.13' }, }); }); test('runtime version info reports the installed sing-box binary', () => { const run = () => ({ stdout: 'sing-box version 1.12.13\n', stderr: '' }); assert.deepEqual(buildVersionInfo('gateway', run), { apiVersion: 1, location: 'gateway', components: { gatewayBackend: HARBOR_VERSIONS.gatewayBackend }, runtime: { singBox: '1.12.13' }, }); }); test('version route returns prebuilt local info without a dataplane refresh', async () => { const res = response(); const route = createVersionRoute({ versionInfo: controlVersionInfo, refreshDataplaneRuntime: null, }); assert.equal(await route.handle({ method: 'GET', url: '/api/version' }, res), true); assert.equal(res.status, 200); assert.equal(res.headers['content-type'], 'application/json; charset=utf-8'); assert.equal(res.rawPayload.endsWith('\n'), false); assert.deepEqual(res.payload, controlVersionInfo); }); test('version route refreshes split Gateway once and preserves remote runtime overlay', async () => { let refreshes = 0; const route = createVersionRoute({ versionInfo: controlVersionInfo, refreshDataplaneRuntime: async () => { refreshes += 1; return { gatewayBackendVersion: '0.21.16', singBoxVersion: '1.12.14', }; }, }); const res = response(); assert.equal(await route.handle({ method: 'GET', url: '/api/version' }, res), true); assert.equal(refreshes, 1); assert.deepEqual(res.payload, { apiVersion: 1, location: 'gateway', components: { gatewayBackend: '0.21.17' }, runtime: { dataplaneVersion: '0.21.16', singBox: '1.12.14' }, }); }); test('version route keeps remote null fallbacks', async () => { const route = createVersionRoute({ versionInfo: controlVersionInfo, refreshDataplaneRuntime: async () => ({}), }); const res = response(); await route.handle({ method: 'GET', url: '/api/version' }, res); assert.deepEqual(res.payload.runtime, { dataplaneVersion: null, singBox: null }); }); test('version route exact guard avoids refresh and refresh errors propagate unchanged', async () => { let refreshes = 0; const refreshError = new Error('status failed'); const route = createVersionRoute({ versionInfo: controlVersionInfo, refreshDataplaneRuntime: async () => { refreshes += 1; throw refreshError; }, }); for (const [method, url] of [ ['POST', '/api/version'], ['GET', '/api/version?source=client'], ['GET', '/api/version/'], ['GET', '/api/other'], ]) { assert.equal(await route.handle({ method, url }, response()), false); } assert.equal(refreshes, 0); const res = response(); await assert.rejects( route.handle({ method: 'GET', url: '/api/version' }, res), (error) => error === refreshError, ); assert.equal(refreshes, 1); assert.equal(res.status, undefined); }); test('version route is the sole HTTP owner', () => { const index = readFileSync(new URL('../../src/server/index.ts', import.meta.url), 'utf8'); const route = readFileSync( new URL('../../src/server/http/routes/versionRoute.ts', import.meta.url), 'utf8', ); assert.match(index, /createVersionRoute\(\{/); assert.match(index, /versionRoute\.handle\(req, res\)/); assert.doesNotMatch(index, /['"]\/api\/version['"]|buildGatewayVersionInfo|sendJson/); assert.match(route, /req\.url !== '\/api\/version'/); assert.match(route, /buildGatewayVersionInfo\(/); });