258 lines
7.4 KiB
JavaScript
258 lines
7.4 KiB
JavaScript
'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 <Release> ',
|
|
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);
|
|
});
|