feat: add safe GitPeji update checks

This commit is contained in:
2026-08-19 21:18:18 +00:00
parent 1215b95df2
commit ceaa0bfdce
2 changed files with 487 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const {
LATEST_RELEASE_URL,
RELEASES_PAGE_URL,
MAX_BODY_BYTES,
checkForUpdate,
compareSemver,
} = 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('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 &lt;Release&gt;',
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', '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',
);
});