'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 initSqlJs = require('sql.js'); const migration = require('../../src/database/migrations/0012-create-trophy-psn-snapshots'); const { SecretsStore } = require('../../src/security/secrets-store'); const { createAuthorizedPsnTrophyProvider, createJsonTrophySnapshotProvider, createSqliteTrophySnapshotRepository, parseTrophySnapshot, } = require('../../src/services/trophy-snapshot-service'); function sample(overrides = {}) { return { schemaVersion: 1, provider: { id: 'user-export', version: '2.4.0' }, account: { accountId: '1234567890123456', onlineId: 'Player_One' }, context: { region: 'BE', language: 'nl-BE' }, fetchedAt: '2026-07-20T12:00:00.000Z', warnings: ['export is read-only'], titles: [{ npCommunicationId: 'NPWR12345_00', titleId: 'PPSA12345', contentId: 'EP0001-PPSA12345_00-GAME000000000001', name: 'Example Game', platform: 'PS5', service: 'trophy2', groups: [{ groupId: 'default', name: 'Base Game' }], trophies: [ { groupId: 'default', trophyId: 2, name: 'Second', grade: 'silver', hidden: false, earned: false, progress: 20 }, { groupId: 'default', trophyId: 1, name: 'First', grade: 'gold', hidden: false, earned: true, earnedDateTime: '2026-01-02T03:04:05Z', progress: 100 }, ], }], ...overrides, }; } async function repository() { const SQL = await initSqlJs(); const db = new SQL.Database(); migration.up(db); return { db, repository: createSqliteTrophySnapshotRepository({ db }) }; } test('JSON import validates, normalizes and stores canonical immutable proof', async t => { const { db, repository: repo } = await repository(); t.after(() => db.close()); const provider = createJsonTrophySnapshotProvider({ repository: repo, now: () => 1800000000000 }); const first = provider.authenticateOrImport({ ...sample(), signature: 'signature-a' }, { filename: 'proof.json' }); const reordered = sample({ warnings: ['export is read-only'], signature: 'signature-b' }); reordered.titles[0].trophies.reverse(); const second = provider.authenticateOrImport(JSON.stringify(reordered), { filename: 'other.json' }); assert.equal(first.id, second.id); assert.equal(first.canonicalSha256, second.canonicalSha256); assert.deepEqual(first.snapshot.titles[0].trophies.map(item => item.trophyId), [1, 2]); assert.equal(repo.list().length, 1); assert.throws(() => db.run('UPDATE trophy_psn_snapshots SET online_id = ?', ['Changed']), /immutable/); const publicModel = provider.sanitizePublicModel(first); assert.equal(publicModel.account.accountId.endsWith('3456'), true); assert.equal(publicModel.account.accountId.includes('123456789012'), false); assert.equal(publicModel.readOnly, true); }); test('invalid JSON, unknown fields, invalid timestamps and duplicate identities fail closed', () => { assert.throws(() => parseTrophySnapshot('{'), error => error.code === 'TROPHY_SCHEMA'); assert.throws(() => parseTrophySnapshot({ ...sample(), writeEndpoint: 'https://example.invalid' }), error => error.code === 'TROPHY_SCHEMA'); const missingTime = sample(); delete missingTime.titles[0].trophies[1].earnedDateTime; assert.throws(() => parseTrophySnapshot(missingTime), error => error.code === 'TROPHY_SCHEMA'); const duplicate = sample(); duplicate.titles[0].trophies.push({ ...duplicate.titles[0].trophies[0] }); assert.throws(() => parseTrophySnapshot(duplicate), error => error.code === 'TROPHY_SCHEMA'); }); test('authorized provider encrypts and redacts its token and revoke retains proof', async t => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-trophy-secrets-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const { db, repository: repo } = await repository(); t.after(() => db.close()); const secrets = new SecretsStore({ filePath: path.join(root, 'secrets.json'), keyFile: path.join(root, 'secret.key') }); const token = 'read-only-token-that-must-never-leak'; const provider = createAuthorizedPsnTrophyProvider({ repository: repo, secrets, fetchPage: async () => ({ titles: [], nextCursor: null }), now: () => 1800000000000 }); const auth = provider.authenticateOrImport({ accessToken: token }); assert.equal(auth.authenticated, true); assert.equal(fs.readFileSync(path.join(root, 'secrets.json'), 'utf8').includes(token), false); assert.equal(JSON.stringify(provider.sanitizePublicModel()).includes(token), false); assert.equal(provider.revoke().snapshotsRetained, true); assert.equal(secrets.has('trophy.psn.readonly.access-token'), false); }); test('authorized provider paginates with bounded GET requests and rate limiting', async t => { const { db, repository: repo } = await repository(); t.after(() => db.close()); const values = new Map([['trophy.psn.readonly.access-token', 'read-only-token-123456']]); const secrets = { set: (key, value) => value ? values.set(key, value) : values.delete(key), get: key => values.get(key) || '', has: key => values.has(key) }; let clock = 1800000000000; const calls = []; const pages = [ { titles: [sample().titles[0]], nextCursor: 'page-2' }, { titles: [{ ...sample().titles[0], npCommunicationId: 'NPWR54321_00', titleId: 'PPSA54321' }], nextCursor: null }, ]; const provider = createAuthorizedPsnTrophyProvider({ repository: repo, secrets, now: () => clock, sleep: async ms => { clock += ms; }, limits: { minIntervalMs: 100, pageSize: 1, pages: 3 }, fetchPage: async request => { calls.push({ method: request.method, cursor: request.cursor, at: clock, token: request.accessToken }); return pages.shift(); }, }); const stored = await provider.fetchSnapshot({ accountId: '1234567890123456', onlineId: 'Player_One', region: 'BE', language: 'nl-BE' }); assert.equal(stored.titleCount, 2); assert.deepEqual(calls.map(call => call.method), ['GET', 'GET']); assert.ok(calls[1].at - calls[0].at >= 100); assert.equal(JSON.stringify(provider.sanitizePublicModel(stored)).includes(calls[0].token), false); const looping = createAuthorizedPsnTrophyProvider({ repository: repo, secrets, now: () => clock, sleep: async ms => { clock += ms; }, limits: { pages: 2, minIntervalMs: 0 }, fetchPage: async ({ cursor }) => ({ titles: [], nextCursor: cursor ? 'third' : 'second' }) }); await assert.rejects(() => looping.fetchSnapshot({ accountId: '1234567890123456', onlineId: 'Player_One', region: 'BE', language: 'nl-BE' }), error => error.code === 'TROPHY_LIMIT'); }); test('snapshot providers expose only read-only remote trophy contracts', () => { const schema = require('../../schemas/trophy-psn-proof.schema.json'); assert.equal(schema.properties.schemaVersion.const, 1); assert.equal(schema.additionalProperties, false); const source = fs.readFileSync(path.resolve(__dirname, '../../src/services/trophy-snapshot-service.js'), 'utf8'); assert.match(source, /capabilities:\s*CAPABILITIES/); assert.doesNotMatch(source, /method:\s*['"](?:POST|PUT|PATCH|DELETE)['"]/i); assert.doesNotMatch(source, /troph(?:y|ies).{0,40}(?:sync|unlock|write)/i); const importer = fs.readFileSync(path.resolve(__dirname, '../../src/services/psn-trophy-history-import-service.js'), 'utf8'); assert.doesNotMatch(importer, /method:\s*['"](?:POST|PUT|PATCH|DELETE)['"]/i); assert.doesNotMatch(importer, /troph(?:y|ies).{0,40}(?:sync|unlock|write)/i); });