'use strict'; const assert = require('node:assert/strict'); const test = require('node:test'); const { EventEmitter } = require('node:events'); const fs = require('node:fs'); const path = require('node:path'); const { LATEST_RELEASE_URL, RELEASES_PAGE_URL, MAX_BODY_BYTES, checkForUpdate, compareSemver, defaultRequest, } = require('../src/update-policy'); function response(body, overrides = {}) { return { statusCode: 200, headers: { 'content-type': 'application/json; charset=utf-8' }, body: Buffer.from(body), url: LATEST_RELEASE_URL, ...overrides, }; } async function rejectsWithCode(promise, code) { await assert.rejects(promise, (error) => { assert.equal(error.code, code); return true; }); } test('accepts a live-shape GitPeji v-tag and normalizes it to bare semver', async () => { const fixture = fs.readFileSync( path.join(__dirname, 'fixtures', 'gitpeji-latest-release.json'), 'utf8', ); const result = await checkForUpdate({ currentVersion: '0.9.0', request: async () => response(fixture), platform: 'win32', arch: 'x64', }); assert.equal(result.status, 'update-available'); assert.equal(result.latestVersion, '1.0.0'); assert.equal(result.releaseName, 'Alta Proxy Tool v1.0.0'); }); test('valid update returns only sanitized, check-only metadata', async () => { let requestOptions; const request = async (options) => { requestOptions = options; return response(JSON.stringify({ id: 42, tag_name: '1.2.3', name: ' Security ', published_at: '2026-08-19T12:34:56Z', body: 'untrusted release notes', html_url: 'https://evil.example/download', assets: [{ browser_download_url: 'https://evil.example/payload.exe' }], })); }; const result = await checkForUpdate({ currentVersion: '1.2.2', request, platform: 'win32', arch: 'x64', }); assert.deepEqual(requestOptions, { url: LATEST_RELEASE_URL, timeoutMs: 5000, maxBodyBytes: MAX_BODY_BYTES, redirects: 'error', }); assert.deepEqual(result, { status: 'update-available', currentVersion: '1.2.2', latestVersion: '1.2.3', releaseName: 'Security <Release>', publishedAt: '2026-08-19T12:34:56.000Z', releasesPageUrl: RELEASES_PAGE_URL, platform: 'win32', arch: 'x64', }); assert.equal(JSON.stringify(result).includes('payload.exe'), false); assert.equal(JSON.stringify(result).includes('release notes'), false); }); test('equal or older release is reported as up to date', async () => { const request = async () => response(JSON.stringify({ tag_name: '2.0.0', name: 'Current', published_at: '2026-08-19T12:34:56Z', })); const result = await checkForUpdate({ currentVersion: '2.0.0', request }); assert.equal(result.status, 'up-to-date'); assert.equal(result.releasesPageUrl, RELEASES_PAGE_URL); }); test('404 means there is no published release', async () => { const request = async () => response('', { statusCode: 404 }); const result = await checkForUpdate({ currentVersion: '1.0.0', request }); assert.deepEqual(result, { status: 'no-release', currentVersion: '1.0.0', releasesPageUrl: RELEASES_PAGE_URL, platform: process.platform, arch: process.arch, }); }); test('malformed JSON and invalid response schema fail closed', async () => { await rejectsWithCode( checkForUpdate({ currentVersion: '1.0.0', request: async () => response('{') }), 'INVALID_RESPONSE', ); await rejectsWithCode( checkForUpdate({ currentVersion: '1.0.0', request: async () => response('[]') }), 'INVALID_RESPONSE', ); await rejectsWithCode( checkForUpdate({ currentVersion: '1.0.0', request: async () => response(JSON.stringify({ tag_name: '1.1.0', name: 7 })), }), 'INVALID_RESPONSE', ); }); test('oversized bodies fail before parsing', async () => { const oversized = Buffer.alloc(MAX_BODY_BYTES + 1, 0x20); await rejectsWithCode( checkForUpdate({ currentVersion: '1.0.0', request: async () => response('', { body: oversized }), }), 'RESPONSE_TOO_LARGE', ); }); test('redirects and response host drift are rejected', async () => { await rejectsWithCode( checkForUpdate({ currentVersion: '1.0.0', request: async () => response('', { statusCode: 302, headers: { location: 'https://evil.example/latest' }, }), }), 'REDIRECT_REJECTED', ); await rejectsWithCode( checkForUpdate({ currentVersion: '1.0.0', request: async () => response('{}', { url: 'https://evil.example/latest' }), }), 'UNTRUSTED_RESPONSE_URL', ); }); test('invalid or non-strict semver fails closed', async () => { const invalidVersions = [ 'V1.2.3', 'vv1.2.3', ' v1.2.3', 'v1.2.3 ', 'v1.2', 'v01.2.3', 'v1.2.3.4', 'vlatest', '1.2', '01.2.3', '1.2.3.4', 'latest', ]; for (const tag_name of invalidVersions) { await rejectsWithCode( checkForUpdate({ currentVersion: '1.0.0', request: async () => response(JSON.stringify({ tag_name })), }), 'INVALID_RELEASE_VERSION', ); } await rejectsWithCode( checkForUpdate({ currentVersion: 'v1.0.0', request: async () => response('{}') }), 'INVALID_CURRENT_VERSION', ); }); test('strict semver comparison handles prerelease precedence', () => { assert.equal(compareSemver('1.0.0', '1.0.0'), 0); assert.equal(compareSemver('1.0.1', '1.0.0'), 1); assert.equal(compareSemver('1.0.0-alpha.2', '1.0.0-alpha.10'), -1); assert.equal(compareSemver('1.0.0-rc.1', '1.0.0'), -1); assert.equal(compareSemver('2.0.0+build.1', '2.0.0+build.2'), 0); }); test('unexpected HTTP and content types fail closed', async () => { await rejectsWithCode( checkForUpdate({ currentVersion: '1.0.0', request: async () => response('server error', { statusCode: 500 }), }), 'HTTP_ERROR', ); await rejectsWithCode( checkForUpdate({ currentVersion: '1.0.0', request: async () => response('{}', { headers: { 'content-type': 'text/html' } }), }), 'INVALID_CONTENT_TYPE', ); }); test('default transport enforces an absolute deadline despite trickled response bytes', async () => { const request = new EventEmitter(); const responseStream = new EventEmitter(); request.destroyedWith = null; responseStream.destroyed = false; request.destroy = (error) => { request.destroyedWith = error; request.emit('error', error); }; request.setTimeout = () => { throw new Error('inactivity timeout must not be used'); }; responseStream.destroy = () => { responseStream.destroyed = true; }; responseStream.headers = { 'content-type': 'application/json' }; responseStream.statusCode = 200; let deadline; let cleared = false; const pending = defaultRequest({ url: LATEST_RELEASE_URL, timeoutMs: 5000, maxBodyBytes: MAX_BODY_BYTES, httpsGet: (_url, _options, onResponse) => { onResponse(responseStream); return request; }, setTimer: (callback, milliseconds) => { assert.equal(milliseconds, 5000); deadline = callback; return 7; }, clearTimer: (timer) => { assert.equal(timer, 7); cleared = true; }, }); responseStream.emit('data', Buffer.from('{')); responseStream.emit('data', Buffer.from(' ')); deadline(); await rejectsWithCode(pending, 'REQUEST_TIMEOUT'); assert.equal(responseStream.destroyed, true); assert.equal(request.destroyedWith.code, 'REQUEST_TIMEOUT'); assert.equal(cleared, true); });