2026-08-07 06:10:29 +09:00
import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest' ;
import express from 'express' ;
import path from 'node:path' ;
import request from 'supertest' ;
vi . mock ( 'child_process' , () => ({
spawn : vi . fn (),
spawnSync : vi . fn (),
}));
vi . mock ( '../package-manager.js' , () => ({
checkForUpdates : vi . fn (),
getUpdateCommand : vi . fn (),
detectPackageManagerDetails : vi . fn (),
}));
const childProcess = await import ( 'child_process' );
const packageManager = await import ( '../package-manager.js' );
const { registerOpenChamberRoutes } = await import ( './openchamber-routes.js' );
2026-09-08 01:26:59 +08:00
const createApp = ({ environment = {}, storedOptions = {}, desktopUpdater } = {}) => {
2026-08-07 06:10:29 +09:00
const app = express ();
const dependencies = {
fs : {
existsSync : vi . fn (() => false ),
promises : {
readFile : vi . fn ( async () => JSON . stringify ({
launchMode : 'foreground' ,
port : 7897 ,
... storedOptions ,
})),
},
},
path ,
process : {
env : environment ,
platform : 'linux' ,
execPath : '/usr/bin/node' ,
},
server : {
address : () => ({ port : 7897 }),
},
__dirname : '/opt/openchamber/server' ,
openchamberDataDir : '/tmp/openchamber' ,
modelsDevApiUrl : 'https://models.example.test' ,
modelsMetadataCacheTtl : 0 ,
readSettingsFromDiskMigrated : vi . fn (),
fetchFreeZenModels : vi . fn (),
getCachedZenModels : vi . fn (),
2026-09-08 01:26:59 +08:00
desktopUpdater ,
2026-08-07 06:10:29 +09:00
};
registerOpenChamberRoutes ( app , dependencies );
return { app , dependencies };
};
beforeEach (() => {
packageManager . checkForUpdates . mockResolvedValue ({
available : true ,
version : '1.17.1' ,
});
packageManager . detectPackageManagerDetails . mockReturnValue ({
packageManager : 'npm' ,
});
packageManager . getUpdateCommand . mockReturnValue ( 'npm install -g @openchamber/web@latest' );
});
afterEach (() => {
vi . restoreAllMocks ();
vi . clearAllMocks ();
});
2026-09-08 01:26:59 +08:00
describe ( 'OpenChamber desktop host update route' , () => {
2026-09-07 20:38:00 +03:00
it ( 'reports a restart rejection until the user retries installation' , async () => {
const desktopUpdater = {
check : vi . fn ( async () => ({ available : true , currentVersion : '1.17.0' , version : '1.17.1' })),
install : vi . fn ( async () => ({ available : true , version : '1.17.1' })),
restart : vi . fn (). mockRejectedValueOnce ( new Error ( 'Signature rejected' )). mockResolvedValue ( undefined ),
};
const { app } = createApp ({ environment : { OPENCHAMBER_RUNTIME : 'desktop' }, desktopUpdater });
const logError = vi . spyOn ( console , 'error' ). mockImplementation (() => {});
await request ( app ). post ( '/api/openchamber/update-install' ). expect ( 200 );
await new Promise ( resolve => setImmediate ( resolve ));
await request ( app ). get ( '/api/openchamber/update-check?appType=web&reportUsage=false&updateStatus=true' ). expect ( 503 , {
code : 'DESKTOP_UPDATE_RESTART_FAILED' , error : 'Signature rejected' ,
});
expect ( desktopUpdater . check ). not . toHaveBeenCalled ();
expect ( logError ). toHaveBeenCalledOnce ();
// Availability remains reachable after a browser reload, so users can retry.
await request ( app ). get ( '/api/openchamber/update-check?appType=web&reportUsage=false' ). expect ( 200 );
await request ( app ). post ( '/api/openchamber/update-install' ). expect ( 200 );
await new Promise ( resolve => setImmediate ( resolve ));
const response = await request ( app ). get ( '/api/openchamber/update-check?appType=web&reportUsage=false' ). expect ( 200 );
expect ( response . body . currentVersion ). toBe ( '1.17.0' );
expect ( response . body . updateOwner ). toBe ( 'electron-updater' );
expect ( packageManager . checkForUpdates ). not . toHaveBeenCalled ();
});
it ( 'rejects native checks without a bridge and preserves explicit non-web checks' , async () => {
const { app } = createApp ({ environment : { OPENCHAMBER_RUNTIME : 'desktop' } });
await request ( app ). get ( '/api/openchamber/update-check?appType=web' ). expect ( 503 , {
available : false , code : 'DESKTOP_UPDATER_UNAVAILABLE' , error : 'The desktop updater is not available.' ,
});
expect ( packageManager . checkForUpdates ). not . toHaveBeenCalled ();
await request ( app ). get ( '/api/openchamber/update-check?appType=desktop-electron' ). expect ( 200 );
expect ( packageManager . checkForUpdates ). toHaveBeenCalledOnce ();
});
2026-09-08 01:26:59 +08:00
it ( 'uses electron-updater to check for Web client updates' , async () => {
const desktopUpdater = {
check : vi . fn ( async () => ({
available : true ,
currentVersion : '1.17.0' ,
version : '1.17.1' ,
})),
install : vi . fn (),
restart : vi . fn (),
};
const { app } = createApp ({
environment : {
OPENCHAMBER_RUNTIME : 'desktop' ,
},
desktopUpdater ,
});
await request ( app )
. get ( '/api/openchamber/update-check?appType=web&reportUsage=false' )
. expect ( 200 , {
available : true ,
currentVersion : '1.17.0' ,
version : '1.17.1' ,
packageManager : 'electron' ,
updateOwner : 'electron-updater' ,
});
expect ( desktopUpdater . check ). toHaveBeenCalledOnce ();
expect ( packageManager . checkForUpdates ). not . toHaveBeenCalled ();
});
it ( 'installs through electron-updater and restarts after responding' , async () => {
const desktopUpdater = {
check : vi . fn (),
install : vi . fn ( async () => ({
available : true ,
version : '1.17.1' ,
})),
restart : vi . fn (),
};
const { app } = createApp ({
environment : {
OPENCHAMBER_RUNTIME : 'desktop' ,
},
desktopUpdater ,
});
await request ( app )
. post ( '/api/openchamber/update-install' )
. expect ( 200 , {
success : true ,
message : 'Desktop update downloaded, host will restart shortly' ,
version : '1.17.1' ,
packageManager : 'electron' ,
updateOwner : 'electron-updater' ,
autoRestart : true ,
restartManager : 'electron-updater' ,
});
await new Promise (( resolve ) => setImmediate ( resolve ));
expect ( desktopUpdater . install ). toHaveBeenCalledOnce ();
expect ( desktopUpdater . restart ). toHaveBeenCalledOnce ();
expect ( packageManager . checkForUpdates ). not . toHaveBeenCalled ();
expect ( packageManager . detectPackageManagerDetails ). not . toHaveBeenCalled ();
expect ( packageManager . getUpdateCommand ). not . toHaveBeenCalled ();
expect ( childProcess . spawn ). not . toHaveBeenCalled ();
expect ( childProcess . spawnSync ). not . toHaveBeenCalled ();
});
it ( 'fails safely when the Electron updater bridge is unavailable' , async () => {
const { app } = createApp ({
environment : {
OPENCHAMBER_RUNTIME : 'desktop' ,
},
});
await request ( app )
. post ( '/api/openchamber/update-install' )
. expect ( 503 , {
code : 'DESKTOP_UPDATER_UNAVAILABLE' ,
error : 'The desktop updater is not available.' ,
});
expect ( packageManager . checkForUpdates ). not . toHaveBeenCalled ();
expect ( childProcess . spawn ). not . toHaveBeenCalled ();
});
});
2026-08-07 06:10:29 +09:00
describe ( 'OpenChamber foreground update route' , () => {
it ( 'rejects a foreground update when the server is not owned by systemd' , async () => {
const { app } = createApp ();
await request ( app )
. post ( '/api/openchamber/update-install' )
. expect ( 409 , {
error : 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.' ,
});
expect ( childProcess . spawnSync ). not . toHaveBeenCalled ();
});
it ( 'rejects an unsafe systemd unit override before starting an update job' , async () => {
const { app } = createApp ({
environment : {
INVOCATION_ID : 'systemd-invocation' ,
OPENCHAMBER_SYSTEMD_UNIT : 'openchamber.service; rm -rf /' ,
},
});
await request ( app )
. post ( '/api/openchamber/update-install' )
. expect ( 409 , {
error : 'Foreground servers must be updated by their service manager. Set OPENCHAMBER_SYSTEMD_UNIT when running under systemd, or run openchamber update and restart the service.' ,
});
expect ( childProcess . spawnSync ). not . toHaveBeenCalled ();
});
it ( 'queues the install in a transient systemd unit and returns its job identifier' , async () => {
vi . spyOn ( Date , 'now' ). mockReturnValue ( 1_700_000_000_000 );
childProcess . spawnSync . mockReturnValue ({ status : 0 , stdout : '' , stderr : '' });
const { app } = createApp ({
environment : {
INVOCATION_ID : 'systemd-invocation' ,
OPENCHAMBER_SYSTEMD_UNIT : 'openchamber@wsl.service' ,
PATH : '/home/syu/.npm-global/bin:/usr/bin:/bin' ,
},
});
await request ( app )
. post ( '/api/openchamber/update-install' )
. expect ( 200 , {
success : true ,
message : 'Update queued; OpenChamber will restart after installation completes' ,
version : '1.17.1' ,
packageManager : 'npm' ,
autoRestart : true ,
restartManager : 'systemd' ,
jobId : 'openchamber-update-1700000000000' ,
logPath : 'journalctl --user-unit openchamber-update-1700000000000.service' ,
});
expect ( childProcess . spawnSync ). toHaveBeenCalledWith ( 'systemd-run' , [
'--user' ,
'--unit=openchamber-update-1700000000000' ,
'--collect' ,
'--service-type=exec' ,
'--setenv=PATH=/home/syu/.npm-global/bin:/usr/bin:/bin' ,
'/bin/sh' ,
'-c' ,
"set -eu\nnpm install -g @openchamber/web@latest\nsystemctl --user restart 'openchamber@wsl.service'" ,
], {
encoding : 'utf8' ,
stdio : [ 'ignore' , 'pipe' , 'pipe' ],
timeout : 5000 ,
});
});
});