271 lines
14 KiB
JavaScript
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;
|
|
}
|
|
});
|