155 lines
5.7 KiB
JavaScript
155 lines
5.7 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const test = require('node:test');
|
|
|
|
const { canonicalCatalogDigest } = require('../../src/services/legacy-detached-trophy-preflight-service');
|
|
const {
|
|
CONSENT,
|
|
canonicalTrophyDigest,
|
|
confirmationPhrase,
|
|
createInactiveLegacyTrophyUnlockService,
|
|
} = require('../../src/services/inactive-legacy-trophy-unlock-service');
|
|
|
|
const game = {
|
|
title: 'Inactive fixture game',
|
|
trophies: [
|
|
{ id: 0, grade: 'bronze', name: 'Locked', description: 'Target', hidden: false, earned: false },
|
|
{ id: 1, grade: 'silver', name: 'Earned', description: 'Existing', hidden: false, earned: true },
|
|
],
|
|
};
|
|
const catalogSha256 = canonicalCatalogDigest(game);
|
|
const trophySha256 = canonicalTrophyDigest(catalogSha256, game.trophies[0]);
|
|
const backupSha256 = 'b'.repeat(64);
|
|
|
|
function dependencies(overrides = {}) {
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
values: {
|
|
installedCatalog: async () => ({ games: [game] }),
|
|
mutationStatus: async () => ({ available: true }),
|
|
detachedPreflight: async (input) => {
|
|
calls.push(['preflight', input]);
|
|
return { ready: true, cleanupComplete: true, targetInactive: true };
|
|
},
|
|
backup: async (input) => {
|
|
calls.push(['backup', input]);
|
|
return { complete: true, sha256: backupSha256 };
|
|
},
|
|
dryRun: async (input) => {
|
|
calls.push(['dry-run', input]);
|
|
return { ready: true, writes: 0, unlockCalls: 0 };
|
|
},
|
|
unlockAdapter: async (input) => {
|
|
calls.push(['unlock', input]);
|
|
return {
|
|
status: 'unlocked',
|
|
catalogSha256,
|
|
trophySha256,
|
|
targetInactive: true,
|
|
unlockCalls: 1,
|
|
psnRequests: 0,
|
|
contextDestroyed: true,
|
|
handleDestroyed: true,
|
|
filesPersisted: 0,
|
|
};
|
|
},
|
|
postScan: async (input) => {
|
|
calls.push(['post-scan', input]);
|
|
return { selectedUnlocked: true, changedTrophies: 1, otherChanges: 0 };
|
|
},
|
|
rollback: async (input) => {
|
|
calls.push(['rollback', input]);
|
|
return { verified: true };
|
|
},
|
|
audit: async (input) => calls.push(['audit', input]),
|
|
...overrides,
|
|
},
|
|
};
|
|
}
|
|
|
|
function input() {
|
|
return {
|
|
catalogSha256,
|
|
trophySha256,
|
|
confirmation: confirmationPhrase(catalogSha256, trophySha256),
|
|
};
|
|
}
|
|
|
|
test('inactive unlock is unavailable until a native mutation adapter is proven', async () => {
|
|
const service = createInactiveLegacyTrophyUnlockService({
|
|
installedCatalog: async () => ({ games: [game] }),
|
|
});
|
|
assert.deepEqual(await service.status(), {
|
|
available: false,
|
|
exactOne: true,
|
|
inactiveOnly: true,
|
|
backupRequired: true,
|
|
dryRunRequired: true,
|
|
rollbackRequired: true,
|
|
reasonCode: 'NATIVE_MUTATION_ADAPTER_UNAVAILABLE',
|
|
});
|
|
await assert.rejects(service.unlockOne(input()), /unavailable/i);
|
|
});
|
|
|
|
test('inactive unlock resolves an opaque exact-one selection and verifies the full recovery flow', async () => {
|
|
const fixture = dependencies();
|
|
const service = createInactiveLegacyTrophyUnlockService(fixture.values);
|
|
const result = await service.unlockOne(input());
|
|
|
|
assert.equal(result.status, 'unlocked');
|
|
assert.equal(result.postScanVerified, true);
|
|
assert.equal(result.backupSha256, backupSha256);
|
|
assert.deepEqual(fixture.calls.map(([name]) => name), [
|
|
'preflight', 'backup', 'dry-run', 'unlock', 'post-scan', 'audit',
|
|
]);
|
|
const adapterInput = fixture.calls.find(([name]) => name === 'unlock')[1];
|
|
assert.equal(adapterInput.consent, CONSENT);
|
|
assert.equal(adapterInput.trophyId, 0);
|
|
assert.equal(Object.hasOwn(adapterInput, 'titleId'), false);
|
|
assert.equal(Object.hasOwn(adapterInput, 'npCommunicationId'), false);
|
|
});
|
|
|
|
test('inactive unlock rejects free ids, stale digests, earned trophies and wrong confirmation', async () => {
|
|
const fixture = dependencies();
|
|
const service = createInactiveLegacyTrophyUnlockService(fixture.values);
|
|
await assert.rejects(service.unlockOne({ ...input(), trophyId: 99 }), /forbidden input field/i);
|
|
await assert.rejects(service.unlockOne({ ...input(), trophySha256: '0'.repeat(64) }), /stale or ambiguous/i);
|
|
await assert.rejects(service.unlockOne({ ...input(), confirmation: 'yes' }), /exact inactive trophy confirmation/i);
|
|
|
|
const earnedSha256 = canonicalTrophyDigest(catalogSha256, game.trophies[1]);
|
|
await assert.rejects(service.unlockOne({
|
|
catalogSha256,
|
|
trophySha256: earnedSha256,
|
|
confirmation: confirmationPhrase(catalogSha256, earnedSha256),
|
|
}), /already unlocked/i);
|
|
assert.equal(fixture.calls.length, 0);
|
|
});
|
|
|
|
test('inactive unlock rolls back when exact-one post-scan proof fails', async () => {
|
|
const fixture = dependencies({
|
|
postScan: async () => ({ selectedUnlocked: true, changedTrophies: 2, otherChanges: 1 }),
|
|
});
|
|
const service = createInactiveLegacyTrophyUnlockService(fixture.values);
|
|
await assert.rejects(service.unlockOne(input()), /exact-one trophy mutation/i);
|
|
assert.equal(fixture.calls.some(([name]) => name === 'rollback'), true);
|
|
const audit = fixture.calls.find(([name]) => name === 'audit')[1];
|
|
assert.equal(audit.outcome, 'rolled-back');
|
|
});
|
|
|
|
test('inactive unlock rejects an unverifiable backup or side-effecting dry-run before mutation', async () => {
|
|
const noBackup = dependencies({ backup: async () => ({ complete: false }) });
|
|
await assert.rejects(
|
|
createInactiveLegacyTrophyUnlockService(noBackup.values).unlockOne(input()),
|
|
/verified trophy backup/i,
|
|
);
|
|
|
|
const dirtyDryRun = dependencies({ dryRun: async () => ({ ready: true, writes: 1, unlockCalls: 0 }) });
|
|
await assert.rejects(
|
|
createInactiveLegacyTrophyUnlockService(dirtyDryRun.values).unlockOne(input()),
|
|
/dry-run did not pass/i,
|
|
);
|
|
assert.equal(dirtyDryRun.calls.some(([name]) => name === 'unlock'), false);
|
|
});
|