Files
chimera-Public/test/server/operations-service.test.js
T

271 lines
14 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const JSZip = require('jszip');
const { SettingsStore } = require('../../src/persistence/settings-store');
const {
createOperationsService,
compareVersions,
TASK_DEFINITIONS,
parseShadowMountStatus,
parseShadowMountModel,
analyzeShadowMountConfig,
} = require('../../src/services/operations-service');
function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-operations-'));
const paths = Object.fromEntries(['backups', 'cache', 'tmp', 'logs'].map(name => [name, path.join(root, name)]));
for (const directory of Object.values(paths)) fs.mkdirSync(directory, { recursive: true });
const gamesRoot = path.join(root, 'games');
fs.mkdirSync(path.join(gamesRoot, 'CUSA00001 Game'), { recursive: true });
fs.mkdirSync(path.join(gamesRoot, 'PPSA99999 Orphan'), { recursive: true });
fs.writeFileSync(path.join(gamesRoot, 'unfinished.pkg.partial'), 'partial');
const settings = new SettingsStore(path.join(root, 'config', 'settings.json'));
settings.set('ftp.host', '127.0.0.1');
const runs = [];
let restoredDatabase = null;
const databaseBuffer = Buffer.alloc(128);
databaseBuffer.write('SQLite format 3\0', 0, 'binary');
const db = {
startAutomationRun({ task_id, trigger, started_at = Date.now() }) {
runs.unshift({ id: runs.length + 1, task_id, trigger, status: 'running', started_at });
return runs[0].id;
},
finishAutomationRun(id, patch) { Object.assign(runs.find(run => run.id === id), patch, { finished_at: Date.now(), duration_ms: 1 }); },
listAutomationRuns(limit = 100) { return runs.slice(0, limit); },
exportDatabaseBuffer: () => Buffer.from(databaseBuffer),
restoreDatabaseBuffer(buffer) { restoredDatabase = Buffer.from(buffer); return { ok: true, bytes: buffer.length }; },
listGames: () => [{ game_id: 'CUSA00001', title: 'Known game' }],
listBackups: () => [
{ id: 1, game_id: 'CUSA00001', backup_path: path.join(gamesRoot, 'missing'), source: 'local' },
{ id: 2, game_id: 'CUSA00001', backup_path: path.join(gamesRoot, 'CUSA00001 Game'), source: 'local' },
],
};
const remote = new Map([
['/data/etaHEN/config.ini', Buffer.from('[etaHEN]\n')],
['/data/shadowmount/config.ini', Buffer.from('[shadowmount]\n')],
['/data/shadowmount/manual.lst', Buffer.from('/data/game/CUSA00001.ffpkg\n/mnt/usb0/PPSA99999.ffpfs\n')],
['/data/shadowmount/manual.status', Buffer.from('installed\tCUSA00001\t/data/game/CUSA00001.ffpkg\tKnown game\ndeleted\tPPSA11111\t/mnt/usb0/PPSA11111.exfat\tDeleted game\nlegacy-status\n')],
['/data/shadowmount/debug.log', Buffer.from('ready\n')],
]);
const ftp = {
isConnected: () => true,
hasCredentials: () => true,
listDir: async directory => [...remote.entries()]
.filter(([remotePath]) => path.posix.dirname(remotePath) === directory)
.map(([remotePath, content]) => ({ name: path.posix.basename(remotePath), isDir: false, size: content.length })),
downloadFile: async (remotePath, localPath) => {
if (!remote.has(remotePath)) throw new Error('missing');
fs.writeFileSync(localPath, remote.get(remotePath));
},
uploadFile: async (localPath, remotePath) => remote.set(remotePath, fs.readFileSync(localPath)),
};
const logger = { file: path.join(paths.logs, 'chimera-server.log'), info() {}, warn() {}, error() {} };
fs.writeFileSync(logger.file, JSON.stringify({ time: new Date().toISOString(), level: 'info', values: ['test'] }) + '\n');
const service = createOperationsService({
db, ftp, settings, paths, logger, localRoots: () => [gamesRoot], version: '1.0.9',
taskRunners: {
'library-scan': async () => ({ found: 1 }),
'metadata-refresh': async () => ({ queued: 1 }),
'payload-sync': async () => ({ sources: 1 }),
},
});
return { root, paths, gamesRoot, settings, runs, db, ftp, remote, service, restored: () => restoredDatabase };
}
test('operations service schedules bounded tasks and records runs', async () => {
const f = fixture();
try {
const configured = f.service.configureTask({ taskId: 'library-scan', enabled: true, intervalMinutes: 1 });
assert.equal(configured.tasks.find(item => item.id === 'library-scan').intervalMinutes, 5);
const result = await f.service.runTask('library-scan', 'test');
assert.deepEqual(result, { found: 1 });
assert.equal(f.runs[0].status, 'completed');
assert.equal(f.runs[0].task_id, 'library-scan');
} finally { f.service.stop(); fs.rmSync(f.root, { recursive: true, force: true }); }
});
test('automation includes daily cheat and patch synchronization', () => {
const task = TASK_DEFINITIONS.find(item => item.id === 'cheat-sync');
assert.ok(task);
assert.equal(task.defaultIntervalMinutes, 1_440);
assert.equal(task.defaultEnabled, undefined);
});
test('ShadowMount library proposal is read-only and preserves unresolved sources', async () => {
const f = fixture();
try {
f.settings.set('shadowmount.pathMappings', [{ serverRoot: f.gamesRoot, consoleRoot: '/mnt/usb0/games' }]);
const before = f.remote.get('/data/shadowmount/manual.lst').toString();
const proposal = await f.service.shadowMountProposal();
assert.equal(proposal.automaticWrite, false);
assert.ok(proposal.proposedManual.includes('/mnt/usb0/games/CUSA00001'));
assert.equal(f.remote.get('/data/shadowmount/manual.lst').toString(), before);
} finally {
f.service.stop();
fs.rmSync(f.root, { recursive: true, force: true });
}
});
test('recovery vault snapshots, exports and restores local and fixed remote files', async () => {
const f = fixture();
try {
const snapshot = await f.service.createSnapshot({ label: 'Known good', includeRemote: true });
assert.match(snapshot.id, /^\d{8}T\d{6}Z-[a-f0-9]{8}$/);
assert.equal(snapshot.remote.filter(item => item.included).length, 4);
assert.equal(f.service.listSnapshots()[0].label, 'Known good');
const archive = await f.service.exportSnapshot(snapshot.id);
assert.equal(Buffer.isBuffer(archive), true);
assert.ok(archive.length > 100);
f.remote.set('/data/shadowmount/config.ini', Buffer.from('changed'));
const restored = await f.service.restoreSnapshot({ id: snapshot.id, restoreRemote: true });
assert.equal(restored.ok, true);
assert.equal(f.restored().subarray(0, 16).toString('binary'), 'SQLite format 3\0');
assert.equal(f.remote.get('/data/shadowmount/config.ini').toString(), '[shadowmount]\n');
assert.ok(f.service.listSnapshots().length >= 2, 'restore creates a pre-restore safety point');
} finally { f.service.stop(); fs.rmSync(f.root, { recursive: true, force: true }); }
});
test('recovery schema 2 verifies hashes, imports under a new id and never auto-restores', async () => {
const f = fixture();
try {
const source = await f.service.createSnapshot({ label: 'Portable', includeRemote: true });
assert.equal(source.schema, 2);
assert.equal(f.service.verifySnapshot(source.id, { requireHashes: true }).integrity, 'sha256');
const archive = await f.service.exportSnapshot(source.id);
const imported = await f.service.importSnapshot(archive);
assert.notEqual(imported.id, source.id);
assert.equal(imported.importedFromId, source.id);
assert.equal(imported.restoreExecuted, false);
assert.equal(imported.verification.integrity, 'sha256');
assert.equal(f.restored(), null);
const zip = await JSZip.loadAsync(archive);
zip.file('settings.json', JSON.stringify({ tampered: true }));
const tampered = await zip.generateAsync({ type: 'nodebuffer' });
await assert.rejects(() => f.service.importSnapshot(tampered), /SHA-256|grootte/);
const unsafe = new JSZip();
unsafe.file('manifest.json', '{}'); unsafe.file('chimera.db', Buffer.alloc(128)); unsafe.file('settings.json', '{}'); unsafe.file('unexpected.bin', 'x');
const unsafeArchive = await unsafe.generateAsync({ type: 'nodebuffer' });
await assert.rejects(() => f.service.importSnapshot(unsafeArchive), /onbekend of onveilig pad/);
} finally { f.service.stop(); fs.rmSync(f.root, { recursive: true, force: true }); }
});
test('storage, compatibility and ShadowMount validation are conservative', async () => {
const f = fixture();
try {
const report = f.service.auditStorage();
assert.equal(report.summary.missingBackups, 1);
assert.equal(report.summary.duplicateGames, 1);
assert.equal(report.summary.partialFiles, 1);
assert.equal(report.summary.orphanGameFolders, 1);
let compatibility = f.service.setProfile({ firmware: '7.61', hen: 'etaHEN', kstuffVersion: '1.06' });
assert.equal(compatibility.checks.find(item => item.id === 'firmware').status, 'compatible');
assert.equal(compatibility.checks.find(item => item.id === 'shadowmount-kstuff').status, 'warning');
compatibility = f.service.setProfile({ firmware: '11.00', hen: 'etaHEN', kstuffVersion: '1.07' });
assert.equal(compatibility.checks.find(item => item.id === 'firmware').status, 'warning');
assert.equal(compareVersions('1.07', '1.07'), 0);
const shadow = await f.service.shadowMountStatus();
assert.equal(shadow.model.summary.active, 2);
assert.equal(shadow.model.summary.installed, 1);
assert.equal(shadow.model.summary.pending, 1);
assert.equal(shadow.model.summary.deleted, 1);
assert.equal(shadow.model.summary.experimental, 1);
assert.equal(shadow.model.summary.libraryMatches, 1);
assert.equal(shadow.model.unknownStatusLines[0].text, 'legacy-status');
assert.deepEqual(f.service.shadowMountForGame('CUSA00001'), { state: 'installed', sources: 1, experimental: false, formats: ['ffpkg'] });
await assert.rejects(() => f.service.saveShadowMount({ manual: '../unsafe' }), /Ongeldig absoluut pad/);
const saved = await f.service.saveShadowMount({ config: '[new]\n', manual: '/data/game/CUSA00001\n' });
assert.equal(saved.ok, true);
assert.equal(f.remote.get('/data/shadowmount/config.ini').toString(), '[new]\n');
assert.throws(() => f.service.installPackageUrl({ url: 'http://example.test/file.pkg' }), /HTTPS/);
} finally { f.service.stop(); fs.rmSync(f.root, { recursive: true, force: true }); }
});
test('ShadowMount parsers preserve unknown status and classify supported image formats', () => {
const parsed = parseShadowMountStatus('installed\tCUSA12345\t/mnt/usb0/CUSA12345.ffpkg\tDemo\ninvalid line\n');
assert.equal(parsed.entries.length, 1);
assert.equal(parsed.entries[0].titleName, 'Demo');
assert.equal(parsed.unknownLines.length, 1);
const model = parseShadowMountModel(
'/mnt/usb0/CUSA12345.ffpkg\n/mnt/usb0/PPSA54321.ffpfsc\nrelative/path\n',
'installed\tCUSA12345\t/mnt/usb0/CUSA12345.ffpkg\tDemo\n',
[{ game_id: 'CUSA12345', title: 'Library title' }],
);
assert.equal(model.summary.total, 3);
assert.equal(model.summary.installed, 1);
assert.equal(model.summary.pending, 1);
assert.equal(model.summary.invalid, 1);
assert.equal(model.summary.experimental, 1);
assert.equal(model.entries[0].format.support, 'recommended');
assert.equal(model.entries[1].format.support, 'experimental');
const safe = analyzeShadowMountConfig('mount_read_only=ro\nforce_mount=off\nexfat_backend=LVD\nscan_interval_seconds=15\nstability_wait_seconds=10\nlvd_pfs_sector_size=32768\nmd_exfat_sector_size=512\nmd_ufs_sector_size=512\nunknown_future_key=kept\n');
assert.equal(safe.ok, true);
assert.equal(safe.requiresRiskConfirmation, false);
assert.equal(safe.counts.unknown, 1);
const risky = analyzeShadowMountConfig('mount_read_only=0\nforce_mount=1\nimage_rw=CUSA12345.ffpkg\n');
assert.equal(risky.ok, true);
assert.equal(risky.requiresRiskConfirmation, true);
assert.equal(risky.counts.critical, 3);
assert.equal(risky.confirmationPhrase, 'IK BEGRIJP SHADOWMOUNT RISICO');
const invalid = analyzeShadowMountConfig('mount_read_only=banana\nscanpath=../relative\nlvd_ufs_sector_size=3000\n');
assert.equal(invalid.ok, false);
assert.equal(invalid.counts.invalid, 3);
assert.equal(analyzeShadowMountConfig('kstuff_delay=PPSA12345:3601\n').ok, false);
});
test('ShadowMount blocks invalid and unconfirmed critical configuration before any snapshot or upload', async () => {
const f = fixture();
try {
const before = f.service.listSnapshots().length;
await assert.rejects(() => f.service.saveShadowMount({ config: 'mount_read_only=banana\n' }), /faalt de preflight/);
await assert.rejects(() => f.service.saveShadowMount({ config: 'mount_read_only=0\n' }), /IK BEGRIJP SHADOWMOUNT RISICO/);
assert.equal(f.service.listSnapshots().length, before);
const saved = await f.service.saveShadowMount({ config: 'mount_read_only=0\n', confirmation: 'IK BEGRIJP SHADOWMOUNT RISICO' });
assert.equal(saved.preflight.requiresRiskConfirmation, true);
assert.equal(f.remote.get('/data/shadowmount/config.ini').toString(), 'mount_read_only=0\n');
} finally { f.service.stop(); fs.rmSync(f.root, { recursive: true, force: true }); }
});
test('diagnostics include sanitized build provenance', async () => {
const f = fixture();
const previous = {
id: process.env.CHIMERA_BUILD_ID,
revision: process.env.CHIMERA_SOURCE_REVISION,
tree: process.env.CHIMERA_SOURCE_TREE,
builtAt: process.env.CHIMERA_BUILD_DATE,
};
process.env.CHIMERA_BUILD_ID = 'abcdef123456';
process.env.CHIMERA_SOURCE_REVISION = 'abcdef1234567890abcdef1234567890abcdef12';
delete process.env.CHIMERA_SOURCE_TREE;
process.env.CHIMERA_BUILD_DATE = '2026-07-15T10:00:00Z';
try {
const diagnostics = await f.service.diagnosticsSummary();
assert.deepEqual(diagnostics.chimera.build, {
id: 'abcdef123456',
revision: 'abcdef1234567890abcdef1234567890abcdef12',
tree: null,
treeDigest: null,
builtAt: '2026-07-15T10:00:00.000Z',
});
} finally {
f.service.stop();
fs.rmSync(f.root, { recursive: true, force: true });
if (previous.id === undefined) delete process.env.CHIMERA_BUILD_ID; else process.env.CHIMERA_BUILD_ID = previous.id;
if (previous.revision === undefined) delete process.env.CHIMERA_SOURCE_REVISION; else process.env.CHIMERA_SOURCE_REVISION = previous.revision;
if (previous.tree === undefined) delete process.env.CHIMERA_SOURCE_TREE; else process.env.CHIMERA_SOURCE_TREE = previous.tree;
if (previous.builtAt === undefined) delete process.env.CHIMERA_BUILD_DATE; else process.env.CHIMERA_BUILD_DATE = previous.builtAt;
}
});