'use strict'; const assert = require('node:assert/strict'); const test = require('node:test'); const initSqlJs = require('sql.js'); const migration = require('../../src/database/migrations/0018-create-save-recovery-plans'); const { createSaveRecoveryPlanService, createSaveRecoverySimulator, createSqliteSaveRecoveryRepository, createUnsupportedDirectSaveExtractionProvider } = require('../../src/services/save-recovery-plan-service'); const crypto = require('node:crypto'); const sha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex'); const SOURCE = Buffer.from('encrypted-byte-verifiable-save'); const TARGET = Buffer.from('existing-target-save'); const SOURCE_ACCOUNT = '0011223344556677'; const TARGET_ACCOUNT = '8899aabbccddeeff'; async function harness() { const SQL = await initSqlJs(); const db = new SQL.Database(); migration.up(db); let id = 0; const repository = createSqliteSaveRecoveryRepository({ db, now: () => Date.now(), idFactory: () => `test-${++id}` }); const service = createSaveRecoveryPlanService({ repository, now: () => Date.now(), idFactory: () => `plan-${++id}` }); const artifact = { id: 'vault-1', titleId: 'PPSA03016', accountId: SOURCE_ACCOUNT, sha256: sha256(SOURCE), sizeBytes: SOURCE.length, format: 'garlic-encrypted-ps5-title' }; const adapter = createSaveRecoverySimulator({ sourceBytes: SOURCE, targetBytes: TARGET, sourceAccountId: SOURCE_ACCOUNT, targetAccountId: TARGET_ACCOUNT }); return { adapter, artifact, db, repository, service }; } function makePlan(context, overrides = {}) { return context.service.createPlan({ sourceArtifact: context.artifact, sourceAccountId: SOURCE_ACCOUNT, targetAccountId: TARGET_ACCOUNT, adapterStatus: context.adapter.status(), ...overrides }); } test('dry run freezes explicit accounts, hashes and separate backup-gated steps without executing', async () => { const context = await harness(); const plan = makePlan(context); assert.equal(plan.immutable, true); assert.match(plan.planSha256, /^[a-f0-9]{64}$/); assert.equal(plan.sourceAccountId, SOURCE_ACCOUNT); assert.equal(plan.targetAccountId, TARGET_ACCOUNT); assert.deepEqual(plan.steps.map(step => step.id), ['verify-source','backup-target','export-encrypted','resign','import-encrypted','export-target','verify-target']); assert.ok(plan.steps.filter(step => step.requiresBackup).every(step => step.backupStepId === 'backup-target')); assert.equal(plan.automaticExecution, false); assert.equal(plan.destructiveRestore, false); assert.equal(context.adapter.metrics().mutations, 0); await assert.rejects(context.service.execute({ planId: plan.id, adapter: context.adapter, confirmation: plan.confirmationPhrase }), error => error.code === 'SAVE_RECOVERY_CONFIRMATION'); }); test('source hash mismatch blocks before backup or target mutation', async () => { const context = await harness(); const plan = makePlan(context); const adapter = createSaveRecoverySimulator({ sourceBytes: SOURCE, targetBytes: TARGET, sourceAccountId: SOURCE_ACCOUNT, targetAccountId: TARGET_ACCOUNT, sourceHashMismatch: true }); await assert.rejects(context.service.execute({ planId: plan.id, adapter, confirmation: plan.confirmationPhrase, manualStart: true }), error => error.code === 'SAVE_RECOVERY_HASH_MISMATCH' && error.rollbackStatus === 'not-required'); assert.equal(adapter.metrics().mutations, 0); assert.deepEqual(adapter.metrics().target, TARGET); }); test('explicit source and transformed target account mismatches are rejected', async () => { const context = await harness(); assert.throws(() => makePlan(context, { sourceAccountId: TARGET_ACCOUNT }), error => error.code === 'SAVE_RECOVERY_ACCOUNT_MISMATCH'); const plan = makePlan(context); const adapter = createSaveRecoverySimulator({ sourceBytes: SOURCE, targetBytes: TARGET, sourceAccountId: SOURCE_ACCOUNT, targetAccountId: TARGET_ACCOUNT, accountMismatch: true }); await assert.rejects(context.service.execute({ planId: plan.id, adapter, confirmation: plan.confirmationPhrase, manualStart: true }), error => error.code === 'SAVE_RECOVERY_ACCOUNT_MISMATCH'); assert.equal(adapter.metrics().mutations, 0); }); test('failure after import restores and byte-verifies the pre-action target backup', async () => { const context = await harness(); const plan = makePlan(context); const adapter = createSaveRecoverySimulator({ sourceBytes: SOURCE, targetBytes: TARGET, sourceAccountId: SOURCE_ACCOUNT, targetAccountId: TARGET_ACCOUNT, failAt: 'export-target' }); await assert.rejects(context.service.execute({ planId: plan.id, adapter, confirmation: plan.confirmationPhrase, manualStart: true }), error => error.rollbackStatus === 'rolled-back'); assert.equal(adapter.metrics().mutations, 1); assert.deepEqual(adapter.metrics().target, TARGET); }); test('supported flow journals before/after hashes and idempotent rerun does not mutate', async () => { const context = await harness(); const plan = makePlan(context); const first = await context.service.execute({ planId: plan.id, adapter: context.adapter, confirmation: plan.confirmationPhrase, manualStart: true }); assert.equal(first.status, 'completed'); assert.equal(first.steps.length, 7); assert.ok(first.steps.every(step => step.status === 'completed')); assert.match(first.steps.find(step => step.id === 'backup-target').outputSha256, /^[a-f0-9]{64}$/); assert.match(first.steps.find(step => step.id === 'verify-target').outputSha256, /^[a-f0-9]{64}$/); const second = await context.service.execute({ planId: plan.id, adapter: context.adapter, confirmation: plan.confirmationPhrase, manualStart: true }); assert.equal(second.idempotent, true); assert.equal(context.adapter.metrics().mutations, 1); assert.throws(() => context.db.run("UPDATE save_recovery_plans SET title_id='PPSA99999'"), /immutable/i); }); test('unsupported direct extraction provider remains unavailable and cannot create a plan', async () => { const context = await harness(); const provider = createUnsupportedDirectSaveExtractionProvider(); assert.equal(provider.status().available, false); assert.deepEqual(provider.status().capabilities, []); await assert.rejects(provider.extract({}), error => error.code === 'SAVE_RECOVERY_PROVIDER_UNSUPPORTED'); assert.throws(() => context.service.createPlan({ sourceArtifact: { ...context.artifact, format: 'opaque-ps5-backup-candidate' }, sourceAccountId: SOURCE_ACCOUNT, targetAccountId: TARGET_ACCOUNT, adapterStatus: context.adapter.status() }), error => error.code === 'SAVE_RECOVERY_PROVIDER_UNSUPPORTED'); });