'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); });