125 lines
4.5 KiB
JavaScript
125 lines
4.5 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const crypto = require('node:crypto');
|
|
const test = require('node:test');
|
|
|
|
const {
|
|
CONSENT,
|
|
canonicalCatalogDigest,
|
|
createLegacyDetachedTrophyPreflightService,
|
|
normalizeDetachedPreflight,
|
|
} = require('../../src/services/legacy-detached-trophy-preflight-service');
|
|
|
|
const game = {
|
|
title: 'Detached test game',
|
|
trophies: [
|
|
{ id: 0, grade: 'bronze', name: 'First', description: 'First trophy', hidden: false },
|
|
{ id: 1, grade: 'gold', name: 'Second', description: 'Second trophy', hidden: true },
|
|
],
|
|
};
|
|
|
|
function digest(value) {
|
|
return crypto.createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
function readyResult(catalogSha256) {
|
|
return {
|
|
status: 'ready',
|
|
readOnly: true,
|
|
persisted: false,
|
|
identifiersReturned: false,
|
|
pointersReturned: false,
|
|
catalogSha256,
|
|
targetInactive: true,
|
|
cleanupComplete: true,
|
|
metadata: { paramSfo: true, npbind: true, trophyPackage: true, npTitleAuth: true },
|
|
context: { created: true, registered: true, stateRead: true, destroyed: true },
|
|
handle: { created: true, destroyed: true },
|
|
sideEffects: { unlockCalls: 0, storageWrites: 0, mounts: 0, psnRequests: 0, filesPersisted: 0 },
|
|
};
|
|
}
|
|
|
|
test('detached preflight is fail-closed until both native artifacts are pinned', () => {
|
|
const service = createLegacyDetachedTrophyPreflightService();
|
|
assert.deepEqual(service.status(), {
|
|
available: false,
|
|
readOnly: true,
|
|
reasonCode: 'NATIVE_ADAPTER_NOT_PINNED',
|
|
});
|
|
});
|
|
|
|
test('detached preflight is bound to an exact catalog digest and read-only consent', async () => {
|
|
const cliPath = 'C:\\fixture\\preflight-cli.mjs';
|
|
const elfPath = 'C:\\fixture\\preflight.elf';
|
|
const files = new Map([[cliPath, Buffer.from('cli')], [elfPath, Buffer.from('elf')]]);
|
|
const calls = [];
|
|
const catalogSha256 = canonicalCatalogDigest(game);
|
|
const service = createLegacyDetachedTrophyPreflightService({
|
|
cliPath,
|
|
elfPath,
|
|
expectedCliSha256: digest(files.get(cliPath)),
|
|
expectedElfSha256: digest(files.get(elfPath)),
|
|
fs: {
|
|
existsSync: (candidate) => files.has(candidate),
|
|
readFileSync: (candidate) => files.get(candidate),
|
|
},
|
|
installedCatalog: async () => ({ games: [game] }),
|
|
runAdapter: async (request) => {
|
|
calls.push(request);
|
|
return readyResult(catalogSha256);
|
|
},
|
|
});
|
|
|
|
assert.equal(service.status().available, true);
|
|
const result = await service.preflight({ catalogSha256 });
|
|
assert.equal(result.ready, true);
|
|
assert.equal(result.cleanupComplete, true);
|
|
assert.deepEqual(result.sideEffects, {
|
|
unlockCalls: 0,
|
|
storageWrites: 0,
|
|
mounts: 0,
|
|
psnRequests: 0,
|
|
filesPersisted: 0,
|
|
});
|
|
assert.equal(calls.length, 1);
|
|
assert.equal(calls[0].consent, CONSENT);
|
|
assert.equal(calls[0].catalogSha256, catalogSha256);
|
|
assert.equal(Object.hasOwn(calls[0], 'titleId'), false);
|
|
assert.equal(Object.hasOwn(calls[0], 'trophyId'), false);
|
|
});
|
|
|
|
test('detached preflight rejects a stale or invented selection digest', async () => {
|
|
const cliPath = 'cli';
|
|
const elfPath = 'elf';
|
|
const files = new Map([[cliPath, Buffer.from('cli')], [elfPath, Buffer.from('elf')]]);
|
|
const service = createLegacyDetachedTrophyPreflightService({
|
|
cliPath,
|
|
elfPath,
|
|
expectedCliSha256: digest(files.get(cliPath)),
|
|
expectedElfSha256: digest(files.get(elfPath)),
|
|
fs: { existsSync: (candidate) => files.has(candidate), readFileSync: (candidate) => files.get(candidate) },
|
|
installedCatalog: async () => ({ games: [game] }),
|
|
runAdapter: async () => { throw new Error('must not run'); },
|
|
});
|
|
await assert.rejects(service.preflight({ catalogSha256: '0'.repeat(64) }), /no longer matches/i);
|
|
});
|
|
|
|
test('detached preflight rejects writes, unlocks and incomplete cleanup', () => {
|
|
const catalogSha256 = canonicalCatalogDigest(game);
|
|
const wroteStorage = readyResult(catalogSha256);
|
|
wroteStorage.sideEffects.storageWrites = 1;
|
|
assert.throws(() => normalizeDetachedPreflight(wroteStorage, catalogSha256), /read-only contract/i);
|
|
|
|
const leaked = readyResult(catalogSha256);
|
|
leaked.context.destroyed = false;
|
|
assert.throws(() => normalizeDetachedPreflight(leaked, catalogSha256), /leaked a context|every phase/i);
|
|
|
|
const wrongCatalog = readyResult('f'.repeat(64));
|
|
assert.throws(() => normalizeDetachedPreflight(wrongCatalog, catalogSha256), /catalog binding/i);
|
|
|
|
const leakedIdentifier = readyResult(catalogSha256);
|
|
leakedIdentifier.titleId = 'CUSA00000';
|
|
assert.throws(() => normalizeDetachedPreflight(leakedIdentifier, catalogSha256), /forbidden top-level data/i);
|
|
});
|