fix: cross-verify update API claims against npm registry (#1082)
* fix: cross-verify update API claims against npm registry * Update packages/web/server/lib/package-manager.js Signed-off-by: Islam Nofl <islamnofl.official@gmail.com> * fix: show live server version in AboutDialog instead of stale build-time constant * fix: add comment to empty catch block to satisfy lint no-empty rule * fix: preserve live about dialog version in electron * fix: scope update checks by runtime --------- Signed-off-by: Islam Nofl <islamnofl.official@gmail.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
c924e85a29
commit
a23f5e7545
@@ -29,7 +29,7 @@ function getOpenChamberConfigDir() {
|
||||
}
|
||||
|
||||
function sanitizeInstallScope(scope) {
|
||||
if (scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope;
|
||||
if (scope === 'desktop-electron' || scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope;
|
||||
return 'web';
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ function mapArch(value) {
|
||||
}
|
||||
|
||||
function normalizeAppType(value) {
|
||||
if (value === 'web' || value === 'desktop-tauri' || value === 'vscode') return value;
|
||||
if (value === 'web' || value === 'desktop-electron' || value === 'desktop-tauri' || value === 'vscode') return value;
|
||||
return 'web';
|
||||
}
|
||||
|
||||
@@ -701,10 +701,17 @@ export async function fetchChangelogNotes(fromVersion, toVersion) {
|
||||
export async function checkForUpdates(options = {}) {
|
||||
const currentVersion = options.currentVersion || getCurrentVersion();
|
||||
const pm = detectPackageManager();
|
||||
const appType = normalizeAppType(options.appType);
|
||||
|
||||
if (currentVersion !== 'unknown') {
|
||||
const remote = await checkForUpdatesFromApi(currentVersion, options);
|
||||
if (remote) {
|
||||
if (remote.available && appType === 'web') {
|
||||
const npmLatest = await getLatestVersion();
|
||||
if (!npmLatest || compareVersions(npmLatest, remote.version) < 0) {
|
||||
remote.available = false;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...remote,
|
||||
packageManager: pm,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock child_process to prevent real spawnSync calls that would hang in tests
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
spawnSync: vi.fn(() => ({ status: 0, stdout: '/usr/local/bin', stderr: '' })),
|
||||
}));
|
||||
|
||||
const { checkForUpdates } = await import('./package-manager.js');
|
||||
|
||||
/** Helper: create a fetch mock that routes by URL pattern */
|
||||
function createFetchMock() {
|
||||
const handlers = new Map();
|
||||
|
||||
const mock = vi.fn((url, options) => {
|
||||
const urlStr = typeof url === 'string' ? url : url.toString();
|
||||
|
||||
for (const [pattern, response] of handlers) {
|
||||
if (urlStr.includes(pattern)) {
|
||||
return Promise.resolve(response);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch call: ${urlStr}`));
|
||||
});
|
||||
|
||||
mock.when = (pattern, response) => {
|
||||
handlers.set(pattern, response);
|
||||
return mock;
|
||||
};
|
||||
|
||||
return mock;
|
||||
}
|
||||
|
||||
describe('checkForUpdates', () => {
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = createFetchMock();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// --- Scenario: API says update available, npm confirms ---
|
||||
|
||||
it('returns available=true when both API and npm confirm a newer version', async () => {
|
||||
fetchMock
|
||||
.when('api.openchamber.dev', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
latestVersion: '1.10.0',
|
||||
updateAvailable: true,
|
||||
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||
}),
|
||||
})
|
||||
.when('registry.npmjs.org', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
'dist-tags': { latest: '1.10.0' },
|
||||
}),
|
||||
})
|
||||
.when('raw.githubusercontent.com', {
|
||||
ok: true,
|
||||
text: async () => '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||
});
|
||||
|
||||
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.version).toBe('1.10.0');
|
||||
expect(result.currentVersion).toBe('1.9.10');
|
||||
});
|
||||
|
||||
// --- Scenario (THE FIX): API says update available, npm does NOT have it ---
|
||||
|
||||
it('returns available=false when API claims update but npm has same version', async () => {
|
||||
fetchMock
|
||||
.when('api.openchamber.dev', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
latestVersion: '1.10.0',
|
||||
updateAvailable: true,
|
||||
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||
}),
|
||||
})
|
||||
.when('registry.npmjs.org', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
'dist-tags': { latest: '1.9.10' },
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
});
|
||||
|
||||
it('does not cross-check desktop update claims against npm', async () => {
|
||||
fetchMock
|
||||
.when('api.openchamber.dev', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
latestVersion: '1.10.0',
|
||||
updateAvailable: true,
|
||||
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await checkForUpdates({
|
||||
appType: 'desktop-tauri',
|
||||
currentVersion: '1.9.10',
|
||||
});
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.version).toBe('1.10.0');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('accepts electron desktop update claims without npm cross-checking', async () => {
|
||||
fetchMock
|
||||
.when('api.openchamber.dev', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
latestVersion: '1.10.0',
|
||||
updateAvailable: true,
|
||||
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await checkForUpdates({
|
||||
appType: 'desktop-electron',
|
||||
currentVersion: '1.9.10',
|
||||
});
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.version).toBe('1.10.0');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns available=false when API claims update but npm is behind', async () => {
|
||||
fetchMock
|
||||
.when('api.openchamber.dev', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
latestVersion: '1.10.0',
|
||||
updateAvailable: true,
|
||||
releaseNotes: '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||
}),
|
||||
})
|
||||
.when('registry.npmjs.org', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
'dist-tags': { latest: '1.9.9' },
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
});
|
||||
|
||||
// --- Scenario: API says no update, npm agrees ---
|
||||
|
||||
it('returns available=false when API says no update and versions match', async () => {
|
||||
fetchMock.when('api.openchamber.dev', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
latestVersion: '1.9.10',
|
||||
updateAvailable: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
});
|
||||
|
||||
// --- Scenario: API unreachable, npm fallback ---
|
||||
|
||||
it('returns available=true from npm fallback when API is unreachable and npm has newer version', async () => {
|
||||
fetchMock
|
||||
.when('api.openchamber.dev', Promise.reject(new Error('Network error')))
|
||||
.when('registry.npmjs.org', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
'dist-tags': { latest: '1.10.0' },
|
||||
}),
|
||||
})
|
||||
.when('raw.githubusercontent.com', {
|
||||
ok: true,
|
||||
text: async () => '## [1.10.0] - 2026-05-01\n\n- Great new feature',
|
||||
});
|
||||
|
||||
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.version).toBe('1.10.0');
|
||||
});
|
||||
|
||||
it('returns available=false from npm fallback when API is unreachable and versions match', async () => {
|
||||
fetchMock
|
||||
.when('api.openchamber.dev', Promise.reject(new Error('Network error')))
|
||||
.when('registry.npmjs.org', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
'dist-tags': { latest: '1.9.10' },
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
});
|
||||
|
||||
// --- Scenario: API returns null (bad response), npm fallback ---
|
||||
|
||||
it('returns available=false when API returns non-ok status and versions match on npm', async () => {
|
||||
fetchMock
|
||||
.when('api.openchamber.dev', {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
})
|
||||
.when('registry.npmjs.org', {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
'dist-tags': { latest: '1.9.10' },
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
});
|
||||
|
||||
// --- Scenario: Both API and npm are unreachable ---
|
||||
|
||||
it('returns available=false when both sources are unreachable', async () => {
|
||||
fetchMock
|
||||
.when('api.openchamber.dev', Promise.reject(new Error('Network error')))
|
||||
.when('registry.npmjs.org', Promise.reject(new Error('Registry unreachable')));
|
||||
|
||||
const result = await checkForUpdates({ currentVersion: '1.9.10' });
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user