Files
chimera-Public/test/server/ch-1104-ps5-console-staging.test.js

208 lines
13 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
createPs5ConsoleFtpProvider,
createPs5ConsoleStagingService,
} = require('../../src/services/ps5-console-staging-service');
function fixture(t, overrides = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ps5-stage-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const files = new Map([
['/mnt/sandbox/pfsmnt/PPSA12345-app0/sce_sys/param.json', Buffer.from('{"titleId":"PPSA12345"}')],
['/mnt/sandbox/pfsmnt/PPSA12345-app0/eboot.bin', Buffer.from([0x7f, 0x45, 0x4c, 0x46, 1])],
['/mnt/sandbox/pfsmnt/PPSA12345-patch0/sce_sys/param.json', Buffer.from('{"titleId":"PPSA12345"}')],
['/mnt/addcont/PPSA12345/UP0001-PPSA12345_00-DLC0000000000001/sce_sys/param.json', Buffer.from('{"titleId":"PPSA12345"}')],
]);
const directories = new Map([
['/mnt/sandbox/pfsmnt/', [
{ name: 'PPSA12345-app0', isDir: true },
{ name: 'PPSA12345-patch0', isDir: true },
{ name: 'PPSA12345-app0-patch0-union', isDir: true },
{ name: 'PPSA99999-app0', isDir: true },
]],
['/mnt/sandbox/pfsmnt/PPSA12345-app0', [{ name: 'sce_sys', isDir: true }, { name: 'eboot.bin', isDir: false, size: 5 }]],
['/mnt/sandbox/pfsmnt/PPSA12345-app0/sce_sys', [{ name: 'param.json', isDir: false, size: files.get('/mnt/sandbox/pfsmnt/PPSA12345-app0/sce_sys/param.json').length }]],
['/mnt/sandbox/pfsmnt/PPSA12345-patch0', [{ name: 'sce_sys', isDir: true }]],
['/mnt/sandbox/pfsmnt/PPSA12345-patch0/sce_sys', [{ name: 'param.json', isDir: false, size: files.get('/mnt/sandbox/pfsmnt/PPSA12345-patch0/sce_sys/param.json').length }]],
['/mnt/addcont/PPSA12345/UP0001-PPSA12345_00-DLC0000000000001', [{ name: 'sce_sys', isDir: true }]],
['/mnt/addcont/PPSA12345/UP0001-PPSA12345_00-DLC0000000000001/sce_sys', [{ name: 'param.json', isDir: false, size: files.get('/mnt/addcont/PPSA12345/UP0001-PPSA12345_00-DLC0000000000001/sce_sys/param.json').length }]],
]);
const starts = [];
let failOnce = overrides.failOnce === true;
const provider = {
probe: async () => ({ available: true, readOnly: true, recursiveInventory: true, rangeDownload: true, selfDecryption: 'server-default-unverified' }),
list: async remotePath => {
if (overrides.list) return overrides.list(remotePath, directories);
if (!directories.has(remotePath)) throw new Error(`missing directory ${remotePath}`);
return directories.get(remotePath);
},
listSupplementalComponents: async titleId => titleId === 'PPSA12345' ? [{ type: 'dlc', remotePath: '/mnt/addcont/PPSA12345/UP0001-PPSA12345_00-DLC0000000000001', contentId: 'UP0001-PPSA12345_00-DLC0000000000001' }] : [],
download: async ({ remotePath, localPath, startAt = 0 }) => {
starts.push({ remotePath, startAt });
const bytes = files.get(remotePath);
if (!bytes) throw new Error(`missing file ${remotePath}`);
fs.mkdirSync(path.dirname(localPath), { recursive: true });
if (failOnce && remotePath.endsWith('/eboot.bin')) {
failOnce = false;
fs.appendFileSync(localPath, bytes.subarray(startAt, startAt + 2));
throw new Error('connection dropped');
}
fs.appendFileSync(localPath, bytes.subarray(startAt));
},
};
const service = createPs5ConsoleStagingService({
stagingRoot: path.join(root, 'staging'),
stateFile: path.join(root, 'state', 'jobs.json'),
provider,
idFactory: () => 'stage-job-1',
now: () => 1_800_000_000_000,
});
return { root, files, directories, provider, service, starts };
}
test('discovers only title-bound base/patch roots and trusted DLC descriptors', async t => {
const f = fixture(t);
const discovery = await f.service.discover({ titleId: 'PPSA12345' });
assert.deepEqual(discovery.components.map(item => item.type), ['base', 'update', 'dlc']);
assert.equal(discovery.components.some(item => item.remotePath.includes('union')), false);
assert.equal(discovery.components.some(item => item.remotePath.includes('PPSA99999')), false);
await assert.rejects(() => f.service.discover({ titleId: '../PPSA12345' }), error => error.code === 'PS5_STAGE_TITLE_ID');
});
test('discovers an exact active title sandbox when legacy pfsmnt roots are empty', async t => {
const f = fixture(t, {
list: async (remotePath, directories) => {
if (remotePath === '/mnt/sandbox/pfsmnt/') return [];
if (remotePath === '/mnt/sandbox/') return [
{ name: 'PPSA12345_000', isDir: true },
{ name: 'PPSA12345_000-copy', isDir: true },
{ name: 'PPSA99999_000', isDir: true },
];
if (remotePath === '/mnt/sandbox/PPSA12345_000') return [
{ name: 'app0', isDir: true },
{ name: 'app0-copy', isDir: true },
{ name: 'temp0', isDir: true },
];
if (remotePath === '/mnt/sandbox/PPSA12345_000/app0') return [
{ name: 'sce_sys', isDir: true },
{ name: 'eboot.bin', isDir: false, size: 5 },
];
if (remotePath === '/mnt/sandbox/PPSA12345_000/app0/sce_sys') return [
{ name: 'param.json', isDir: false, size: f.files.get('/mnt/sandbox/pfsmnt/PPSA12345-app0/sce_sys/param.json').length },
];
return directories.get(remotePath);
},
});
f.files.set('/mnt/sandbox/PPSA12345_000/app0/eboot.bin', f.files.get('/mnt/sandbox/pfsmnt/PPSA12345-app0/eboot.bin'));
f.files.set('/mnt/sandbox/PPSA12345_000/app0/sce_sys/param.json', f.files.get('/mnt/sandbox/pfsmnt/PPSA12345-app0/sce_sys/param.json'));
const discovery = await f.service.discover({ titleId: 'PPSA12345' });
const base = discovery.components.find(item => item.type === 'base');
assert.equal(base.remotePath, '/mnt/sandbox/PPSA12345_000/app0');
assert.equal(base.sourceKind, 'active-sandbox-app0');
assert.equal(base.sourceSemantics, 'active-mounted-title-view');
assert.equal(discovery.components.some(item => item.remotePath.includes('copy')), false);
const completed = await f.service.run(f.service.create({ discoveryId: discovery.id, componentIds: ['base'] }).id);
assert.equal(completed.manifest.components[0].source.semantics, 'active-mounted-title-view');
});
test('an incomplete host FTP binding keeps staging unavailable without crashing registry startup', async () => {
const provider = createPs5ConsoleFtpProvider({ isConnected: () => true });
const probe = await provider.probe();
assert.equal(probe.available, false);
assert.equal(probe.recursiveInventory, false);
await assert.rejects(() => provider.list('/mnt/sandbox/pfsmnt/'), error => error.code === 'PS5_STAGE_PROVIDER_UNAVAILABLE');
});
test('rejects hostile names, case collisions and unbounded inventories', async t => {
const hostile = fixture(t, { list: async (remotePath, directories) => remotePath.endsWith('-app0') ? [{ name: '..', isDir: false, size: 1 }] : directories.get(remotePath) });
const discovery = await hostile.service.discover({ titleId: 'PPSA12345' });
const created = hostile.service.create({ discoveryId: discovery.id, componentIds: ['base'] });
await assert.rejects(() => hostile.service.run(created.id), error => error.code === 'PS5_STAGE_REMOTE_NAME');
const collision = fixture(t, { list: async (remotePath, directories) => remotePath.endsWith('-app0') ? [{ name: 'Data.bin', isDir: false, size: 1 }, { name: 'data.bin', isDir: false, size: 1 }] : directories.get(remotePath) });
const second = await collision.service.discover({ titleId: 'PPSA12345' });
await assert.rejects(() => collision.service.run(collision.service.create({ discoveryId: second.id, componentIds: ['base'] }).id), error => error.code === 'PS5_STAGE_CASE_COLLISION');
const bounded = fixture(t, { list: async (remotePath, directories) => remotePath.endsWith('-app0') ? [{ name: 'large.bin', isDir: false, size: 20 }] : directories.get(remotePath) });
const third = await bounded.service.discover({ titleId: 'PPSA12345' });
const limited = createPs5ConsoleStagingService({ stagingRoot: path.join(bounded.root, 'limited'), stateFile: path.join(bounded.root, 'limited.json'), provider: bounded.provider, limits: { maxBytes: 10 }, idFactory: () => 'limited' });
const limitedDiscovery = await limited.discover({ titleId: 'PPSA12345' });
await assert.rejects(() => limited.run(limited.create({ discoveryId: limitedDiscovery.id, componentIds: ['base'] }).id), error => error.code === 'PS5_STAGE_LIMIT');
assert.ok(third.id);
});
test('keeps interrupted data partial, resumes by range and publishes only after hashes match', async t => {
const f = fixture(t, { failOnce: true });
const discovery = await f.service.discover({ titleId: 'PPSA12345' });
const created = f.service.create({ discoveryId: discovery.id, componentIds: ['base'] });
await assert.rejects(() => f.service.run(created.id), /connection dropped/);
assert.equal(f.service.get(created.id).status, 'interrupted');
assert.equal(fs.existsSync(path.join(f.root, 'staging', 'PPSA12345', created.id, 'staged')), false);
f.service.resume(created.id);
const completed = await f.service.run(created.id);
assert.equal(completed.status, 'staged');
assert.equal(completed.manifest.components[0].files.length, 2);
assert.ok(completed.manifest.components[0].files.every(file => /^[a-f0-9]{64}$/.test(file.sha256)));
assert.ok(f.starts.some(item => item.remotePath.endsWith('/eboot.bin') && item.startAt === 2));
assert.equal(fs.existsSync(completed.stagedPath), true);
});
test('fails closed on exact-size mismatch and never publishes incomplete output', async t => {
const f = fixture(t);
f.provider.download = async ({ localPath }) => { fs.mkdirSync(path.dirname(localPath), { recursive: true }); fs.appendFileSync(localPath, 'x'); };
const discovery = await f.service.discover({ titleId: 'PPSA12345' });
const created = f.service.create({ discoveryId: discovery.id, componentIds: ['base'] });
await assert.rejects(() => f.service.run(created.id), error => error.code === 'PS5_STAGE_SIZE');
assert.equal(f.service.get(created.id).status, 'failed');
assert.equal(fs.existsSync(path.join(f.root, 'staging', 'PPSA12345', created.id, 'staged')), false);
});
test('keeps component order deterministic and blocks unsupported update package builds', async t => {
const f = fixture(t);
const discovery = await f.service.discover({ titleId: 'PPSA12345' });
const created = f.service.create({ discoveryId: discovery.id, componentIds: discovery.components.map(item => item.id) });
const completed = await f.service.run(created.id);
assert.deepEqual(completed.manifest.components.map(item => item.type), ['base', 'update', 'dlc']);
assert.deepEqual(completed.manifest.recipe.installOrder.map(item => item.order), [1, 2, 3]);
assert.equal(completed.manifest.components.find(item => item.type === 'update').packageBuild.supported, false);
assert.equal(completed.manifest.components.find(item => item.type === 'base').packageBuild.supported, true);
assert.equal(completed.manifest.components.find(item => item.type === 'dlc').packageBuild.supported, true);
assert.equal(completed.manifest.components.find(item => item.type === 'dlc').packageBuild.mode, 'data-only');
});
test('exposes the staging contract across server, Electron, web and translated premium UI', () => {
const root = path.join(__dirname, '..', '..');
const read = relative => fs.readFileSync(path.join(root, relative), 'utf8');
for (const command of ['probe', 'discover', 'create', 'list', 'get', 'run', 'resume']) {
assert.match(read('src/server/create-registry.js'), new RegExp(`ps5-console-staging:${command}`));
assert.match(read('main.js'), new RegExp(`ps5-console-staging:${command}`));
}
for (const bridge of ['ps5ConsoleStagingDiscover', 'ps5ConsoleStagingCreate', 'ps5ConsoleStagingRun', 'ps5ConsoleStagingResume']) {
assert.match(read('preload.js'), new RegExp(bridge));
assert.match(read('renderer/pork-web-shim.js'), new RegExp(bridge));
}
const html = read('renderer/index.html');
const css = read('renderer/styles/ps5-native-packages.css');
const modal = read('renderer/modules/modal.js');
assert.match(html, /ps5-staging-illustration/);
assert.match(html, /ps5-staging-components/);
assert.match(css, /prefers-reduced-motion/);
assert.match(modal, /ps5ConsoleStagingDiscover/);
assert.match(modal, /data-ps5-stage-resume/);
assert.match(modal, /ps5SourcesFromStagedJob/);
assert.match(modal, /packageBuild\?\.supported/);
const en = JSON.parse(read('build/lang/en.json')).strings;
const nl = JSON.parse(read('build/lang/nl.json')).strings;
for (const key of ['ps5NativePackages.stage.title', 'ps5NativePackages.stage.limit', 'ps5NativePackages.stage.completed']) {
assert.equal(typeof en[key], 'string');
assert.equal(typeof nl[key], 'string');
}
});