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

321 lines
16 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('crypto');
const fs = require('fs');
const JSZip = require('jszip');
const os = require('os');
const path = require('path');
const { SettingsStore } = require('../../src/persistence/settings-store');
const { createEcosystemService, patchIni, iniDiff, SERVICE_DEFINITIONS, parseRepoArchive, parseItemzflowLog, parsePkgbuild } = require('../../src/services/ecosystem-service');
function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ecosystem-'));
const paths = Object.fromEntries(['backups', 'cache', 'tmp'].map(name => [name, path.join(root, name)]));
for (const directory of Object.values(paths)) fs.mkdirSync(directory, { recursive: true });
const settings = new SettingsStore(path.join(root, 'config', 'settings.json'));
settings.set('ftp.host', '127.0.0.1');
const secretValues = new Map();
const secrets = {
set(name, value) { if (value) secretValues.set(name, value); else secretValues.delete(name); },
has: name => secretValues.has(name),
get: name => secretValues.get(name) || '',
};
const remote = new Map([
['/data/etaHEN/config.ini', Buffer.from('# preserve\nFTP=1\nPS5Debug=0\nUnknownFutureKey=42\n')],
['/data/homebrew/Demo/eboot.elf', Buffer.from('ELF')],
['/data/homebrew/Demo/sce_sys/icon0.png', Buffer.from('PNG')],
['/data/etaHEN/plugins/example.elf', Buffer.from('binary TEST00001 version 1.23')],
['/mnt/usb0/etaHEN/plugins/example.elf', Buffer.from('usb TEST00001 version 1.24')],
]);
const directories = new Set([
'/data', '/data/etaHEN', '/data/etaHEN/plugins', '/data/homebrew', '/data/homebrew/Demo', '/data/homebrew/Demo/sce_sys',
'/mnt', '/mnt/usb0', '/mnt/usb0/etaHEN', '/mnt/usb0/etaHEN/plugins',
]);
const children = directory => {
const rows = new Map();
for (const candidate of directories) {
if (candidate === directory || path.posix.dirname(candidate) !== directory) continue;
rows.set(path.posix.basename(candidate), { name: path.posix.basename(candidate), isDir: true, size: 0 });
}
for (const [candidate, content] of remote) {
if (path.posix.dirname(candidate) !== directory) continue;
rows.set(path.posix.basename(candidate), { name: path.posix.basename(candidate), isDir: false, size: content.length });
}
return [...rows.values()];
};
const ftp = {
isConnected: () => true,
hasCredentials: () => true,
listDir: async directory => {
if (!directories.has(directory)) throw new Error('missing directory');
return children(directory);
},
downloadFile: async (remotePath, localPath) => {
if (!remote.has(remotePath)) throw new Error('missing file');
fs.writeFileSync(localPath, remote.get(remotePath));
},
uploadFile: async (localPath, remotePath) => {
remote.set(remotePath, fs.readFileSync(localPath));
let current = path.posix.dirname(remotePath);
while (current && current !== '.') { directories.add(current); if (current === '/') break; current = path.posix.dirname(current); }
},
deleteRemote: async remotePath => remote.delete(remotePath),
renameRemote: async (source, destination) => {
if (!remote.has(source)) throw new Error('missing source');
remote.set(destination, remote.get(source)); remote.delete(source);
},
};
const db = {
listGames: () => [{ game_id: 'CUSA00001', title: 'Test Game',
availability: { confirmedInstallCount: 1 },
consoleInstalls: [{ installState: 'observed', installedVersion: '1.00' }] }],
};
const service = createEcosystemService({ db, ftp, settings, secrets, paths, logger: { info() {}, warn() {}, error() {} } });
return { root, paths, settings, secrets, remote, service };
}
test('etaHEN INI patching preserves comments and unknown future keys', () => {
const before = '# comment\nFTP=1\nFutureSetting=abc\n';
const after = patchIni(before, { FTP: 0, Klog: 1 });
assert.match(after, /^# comment/m);
assert.match(after, /^FutureSetting=abc/m);
assert.match(after, /^FTP=0/m);
assert.match(after, /^Klog=1/m);
assert.deepEqual(iniDiff(before, after).map(item => item.key).sort(), ['FTP', 'Klog']);
assert.throws(() => patchIni(before, { UnknownSetting: 1 }), /Onbekende etaHEN-instelling/);
});
test('service matrix covers every Chimera PS5 integration on unique ports', () => {
const ports = SERVICE_DEFINITIONS.map(service => service.port);
assert.equal(new Set(ports).size, ports.length);
for (const required of [1337, 2121, 9021, 6969, 8080, 8082, 8084, 9090, 9081, 9112, 3232, 12800]) {
assert.ok(ports.includes(required), `missing service port ${required}`);
}
});
test('GitHub branch archives are parsed with their generated root removed', async () => {
const zip = new JSZip();
zip.file('PS5_Cheats-main/json/PPSA00001_1.00.json', '{"mods":[]}');
zip.file('PS5_Cheats-main/README.md', '# Cheats');
const entries = await parseRepoArchive(await zip.generateAsync({ type: 'nodebuffer' }));
assert.deepEqual(entries.map(entry => entry.path).sort(), ['README.md', 'json/PPSA00001_1.00.json']);
assert.equal(await entries.find(entry => entry.path.endsWith('.json')).readText(), '{"mods":[]}');
await assert.rejects(() => entries.find(entry => entry.path.endsWith('.json')).readText(2), /te groot/);
});
test('Itemzflow parser separates jobs, warnings and real errors without false positives', () => {
const parsed = parseItemzflowLog([
'[2026-07-16 12:00:00] Installing package 25%',
'[2026-07-16 12:00:01] Daemon returned with no errors',
'[2026-07-16 12:00:02] WARNING mount retry',
'[2026-07-16 12:00:03] ERROR dump failed',
].join('\n'), { id: 'app', label: 'App', path: '/app.log' });
assert.equal(parsed.lines, 4);
assert.equal(parsed.errorCount, 1);
assert.equal(parsed.warningCount, 1);
assert.equal(parsed.latestProgress.progress, 25);
assert.ok(parsed.events.some(event => event.type === 'install'));
assert.ok(parsed.events.some(event => event.type === 'error'));
});
test('PacBrew PKGBUILD metadata is parsed as inert text', () => {
const metadata = parsePkgbuild(`pkgname=ps5-payload-websrv\npkgver=git\npkgdesc="A web server"\nlicense=('GPLv3')\ndepends=('ps5-payload-libmicrohttpd')\nmakedepends=('ps5-payload-sdk' 'ps5-payload-libsmb2')\nsource=("git+https://github.com/ps5-payload-dev/websrv.git")\n`);
assert.equal(metadata.pkgname, 'ps5-payload-websrv');
assert.equal(metadata.description, 'A web server');
assert.deepEqual(metadata.licenses, ['GPLv3']);
assert.deepEqual(metadata.dependencies, ['ps5-payload-libmicrohttpd']);
assert.deepEqual(metadata.buildDependencies, ['ps5-payload-sdk', 'ps5-payload-libsmb2']);
});
test('Itemzflow log center combines every available official log and tolerates missing logs', async () => {
const f = fixture();
try {
f.remote.set('/user/app/ITEM00001/logs/itemzflow_app.log', Buffer.from('Installing package 50%\nDone\n'));
f.remote.set('/data/itemzflow_daemon/daemon.log', Buffer.from('WARNING NFS mount retry\n'));
const packet = await f.service.itemzflowStatus();
assert.equal(packet.online, true);
assert.equal(packet.logs.length, 5);
assert.equal(packet.health.available, 2);
assert.equal(packet.health.missing, 3);
assert.equal(packet.health.errors, 0);
assert.equal(packet.health.warnings, 1);
assert.equal(packet.latestProgress.progress, 50);
assert.ok(packet.events.some(event => event.source === 'daemon'));
} finally { f.service.stop(); fs.rmSync(f.root, { recursive: true, force: true }); }
});
test('etaHEN profile changes are snapshotted, uploaded and verified', async () => {
const f = fixture();
try {
const current = await f.service.getEtaHenConfig();
const preview = await f.service.previewEtaHen({ raw: current.raw, patch: { PS5Debug: 1, Klog: 1 } });
assert.equal(preview.diff.length, 2);
assert.match(preview.proposed, /UnknownFutureKey=42/);
const result = await f.service.applyEtaHen({ proposed: preview.proposed, expectedHash: current.hash, label: 'Test' });
assert.equal(result.changed, 2);
assert.equal(f.service.etaHistory().length, 1);
assert.match(f.remote.get('/data/etaHEN/config.ini').toString(), /PS5Debug=1/);
assert.match(f.remote.get('/data/etaHEN/config.ini').toString(), /Klog=1/);
} finally { f.service.stop(); fs.rmSync(f.root, { recursive: true, force: true }); }
});
test('homebrew and plugins are inventoried with icons, priority and safe toggles', async () => {
const f = fixture();
try {
const homebrew = await f.service.scanHomebrew();
assert.equal(homebrew.items.length, 1);
assert.equal(homebrew.items[0].name, 'Demo');
assert.match(homebrew.items[0].iconUrl, /^\/pork-cache\/homebrew-icons\//);
let plugins = await f.service.scanPlugins();
assert.equal(plugins.items.length, 2);
assert.equal(plugins.items.filter(item => item.duplicate).length, 2);
assert.equal(plugins.items.find(item => item.location === 'usb0').effective, true);
assert.equal(plugins.skippedUnmountedUsbRoots, 6);
const internal = plugins.items.find(item => item.location === 'internal');
const inspected = await f.service.inspectPlugin({ id: internal.id });
assert.deepEqual(inspected.titleIds, ['TEST00001']);
const toggled = await f.service.togglePlugin({ id: internal.id, enabled: false });
assert.equal(toggled.ok, true);
assert.equal(f.remote.has('/data/etaHEN/plugins/example.elf.disabled'), true);
plugins = toggled.inventory;
assert.equal(plugins.items.find(item => item.location === 'internal').enabled, false);
const pluginBytes = Buffer.from('plugin TEST00002 version 2.00');
const installed = await f.service.installPlugin({
filename: 'new-plugin.prx',
data: pluginBytes,
targetRoot: '/data/etaHEN/plugins',
});
assert.equal(installed.path, '/data/etaHEN/plugins/new-plugin.prx');
assert.equal(installed.sha256, crypto.createHash('sha256').update(pluginBytes).digest('hex'));
assert.deepEqual(f.remote.get(installed.path), pluginBytes);
assert.ok(installed.inventory.items.some(item => item.filename === 'new-plugin.prx'));
await assert.rejects(
() => f.service.installPlugin({ filename: '../unsafe.prx', data: pluginBytes, targetRoot: '/data/etaHEN/plugins' }),
/valid \.elf or \.prx plugin file/
);
} finally { f.service.stop(); fs.rmSync(f.root, { recursive: true, force: true }); }
});
test('cheat catalog is filtered and paginated server-side', () => {
const f = fixture();
try {
const items = Array.from({ length: 135 }, (_, index) => ({
key: `cheat-${index}`,
title: `Catalog Game ${String(index).padStart(3, '0')}`,
gameId: index === 0 ? 'CUSA00001' : `PPSA${String(index).padStart(5, '0')}`,
sourceName: 'Test Source',
sourceId: 'test',
path: `/json/cheat-${index}.json`,
platform: index % 2 ? 'ps5' : 'ps4',
kind: 'cheat',
format: 'json',
}));
const cacheFile = path.join(f.paths.cache, 'ecosystem', 'cheats-v2.json');
fs.writeFileSync(cacheFile, JSON.stringify({ schema: 1, syncedAt: 123, items, errors: [] }));
const first = f.service.cheatIndex({ limit: 50 });
assert.equal(first.total, 135);
assert.equal(first.items.length, 50);
assert.equal(first.offset, 0);
assert.equal(first.nextOffset, 50);
assert.equal(first.previousOffset, null);
const last = f.service.cheatIndex({ limit: 50, offset: first.nextOffset });
assert.equal(last.items.length, 50);
assert.equal(last.previousOffset, 0);
assert.equal(last.nextOffset, 100);
const installed = f.service.cheatIndex({ installedOnly: true, limit: 50 });
assert.equal(installed.total, 1);
assert.equal(installed.items[0].installedTitle, 'Test Game');
const searched = f.service.cheatIndex({ query: 'Catalog Game 134', limit: 50 });
assert.equal(searched.total, 1);
assert.equal(searched.items[0].key, 'cheat-134');
} finally { f.service.stop(); fs.rmSync(f.root, { recursive: true, force: true }); }
});
test('remote source passwords never leave the encrypted secret interface', () => {
const f = fixture();
try {
const sources = f.service.saveRemoteSource({ label: 'Unraid', protocol: 'smb', host: 'server.example.test', path: '/Media/roms', username: 'chimera', password: 'secret' });
assert.equal(sources.length, 1);
assert.equal(sources[0].passwordConfigured, true);
assert.equal(Object.prototype.hasOwnProperty.call(sources[0], 'password'), false);
const removed = f.service.removeRemoteSource({ id: sources[0].id });
assert.equal(removed.length, 0);
} finally { f.service.stop(); fs.rmSync(f.root, { recursive: true, force: true }); }
});
test('PS5 Linux Lab is a read-only preflight and warns about active HEN components', () => {
const f = fixture();
try {
f.settings.set('operations.compatibility', { firmware: '7.61', hen: 'etaHEN', henVersion: '2.5B', kstuffVersion: '1.07' });
let report = f.service.linuxLab();
assert.equal(report.status, 'compatible');
assert.equal(report.normalizedFirmware, '7.61');
assert.equal(report.policy.payloadSendDisabled, true);
assert.equal(report.policy.blockDeviceWritesDisabled, true);
assert.equal(report.requirements.find(item => item.id === 'hen').status, 'warning');
assert.equal(report.requirements.find(item => item.id === 'kstuff').status, 'warning');
f.settings.set('operations.compatibility', { firmware: '8.00' });
report = f.service.linuxLab();
assert.equal(report.status, 'incompatible');
assert.equal(report.requirements.find(item => item.id === 'firmware').status, 'incompatible');
} finally { f.service.stop(); fs.rmSync(f.root, { recursive: true, force: true }); }
});
test('Save Vault restore verification rejects tampering and accepts only bounded PS5 title archives', async () => {
const f = fixture();
try {
const titleId = 'PPSA03016';
const id = '1700000000000-test';
const directory = path.join(f.paths.backups, 'save-vault', titleId);
fs.mkdirSync(directory, { recursive: true });
const zip = new JSZip();
zip.file(`${titleId}/sdimg_profile`, Buffer.from([2, 4, 6, 8]));
zip.file(`${titleId}/sdimg_sce_bu_profile`, Buffer.from([1, 3, 5, 7]));
const archive = await zip.generateAsync({ type: 'nodebuffer', compression: 'STORE' });
const filename = `${id}.zip`;
fs.writeFileSync(path.join(directory, filename), archive);
fs.writeFileSync(path.join(directory, `${id}.json`), JSON.stringify({
schema: 1, id, titleId, createdAt: Date.now(), file: filename, bytes: archive.length,
sha256: crypto.createHash('sha256').update(archive).digest('hex'), source: 'test',
}));
const verified = await f.service.verifySaveSnapshot({ id });
assert.equal(verified.ok, true);
assert.equal(verified.archive.titleId, titleId);
assert.equal(verified.archive.entries.length, 2);
assert.equal(verified.archive.entries.filter(entry => entry.backup).length, 1);
fs.appendFileSync(path.join(directory, filename), Buffer.from('tamper'));
const rejected = await f.service.verifySaveSnapshot({ id });
assert.equal(rejected.ok, false);
assert.match(rejected.error, /SHA-256/);
} finally { f.service.stop(); fs.rmSync(f.root, { recursive: true, force: true }); }
});
test('Console Timeline persistence is opt-in, bounded, searchable and redacted', () => {
const f = fixture();
try {
const configured = f.service.configureTimeline({ enabled: true, retentionDays: 7, maxEvents: 100 });
assert.equal(configured.options.enabled, true);
f.service.saveRemoteSource({ protocol: 'smb', host: 'nas.local', port: 445, path: '/roms', label: 'password=supersecret' });
const result = f.service.timelineStatus({ search: 'sources', limit: 10 });
assert.equal(result.filtered, 1);
assert.doesNotMatch(result.events[0].message, /supersecret/);
assert.match(result.events[0].message, /REDACTED/);
const exported = f.service.exportTimeline({ limit: 100 });
assert.doesNotMatch(exported, /supersecret/);
assert.equal(JSON.parse(exported).redacted, true);
f.service.stop();
assert.equal(fs.existsSync(path.join(f.paths.backups, 'ecosystem', 'timeline', 'events.jsonl')), true);
} finally { f.service.stop(); fs.rmSync(f.root, { recursive: true, force: true }); }
});