'use strict'; const assert = require('node:assert/strict'); const test = require('node:test'); const { createInstallRecipeService } = require('../../src/services/install-recipe-service'); const component = (componentId, type, options = {}) => ({ componentId, type, required: options.required ?? true, version: options.version || '1.00', contentId: options.contentId || `UP0001-CUSA12345_00-${componentId.toUpperCase().padEnd(16, 'X')}`, region: options.region || 'US', metadata: { platform: 'ps4', titleId: { value: 'CUSA12345' } } }); function packageSet() { return { id: 7, platform: 'ps4', region: 'US', components: [component('base', 'base'), component('update', 'update', { version: '1.02' }), component('dlc', 'dlc', { required: false })], dependencies: [{ componentId: 'update', dependsOnComponentId: 'base', kind: 'requires' }] }; } const step = (id, componentId, action = 'install-package', options = {}) => ({ id, componentId, action, required: options.required ?? true, dependsOn: options.dependsOn || [], destinationCapabilities: options.destinationCapabilities || [], orderHint: options.orderHint }); const recipe = steps => ({ schemaVersion: 1, recipeVersion: 3, packageSetId: 7, notes: 'Read-only operator guidance.', steps }); const capabilities = { canInstall: true, canCopy: true, canMount: true, canRead: true, stagingPatch: true }; test('DAG validation produces deterministic base-before-update order and frozen job version', () => { const service = createInstallRecipeService({ now: () => 1000 }); const input = recipe([step('update-step', 'update', 'install-package', { orderHint: 1 }), step('base-step', 'base', 'install-package', { orderHint: 2 })]); const first = service.validate(input, packageSet(), { destinationCapabilities: capabilities }); const second = service.validate(input, packageSet(), { destinationCapabilities: capabilities }); assert.deepEqual(first.orderedSteps.map(item => item.id), ['base-step', 'update-step']); assert.deepEqual(first.orderedSteps, second.orderedSteps); const frozen = service.freezeForJob('job-1', input, packageSet(), { destinationCapabilities: capabilities, expectedRecipeVersion: 3 }); assert.equal(frozen.recipeVersion, 3); assert.match(frozen.recipeSha256, /^[0-9a-f]{64}$/); assert.equal(Object.isFrozen(frozen.recipe.orderedSteps), true); assert.throws(() => service.freezeForJob('job-1', input, packageSet(), { destinationCapabilities: capabilities, expectedRecipeVersion: 2 }), error => error.code === 'RECIPE_VERSION_CHANGED'); }); test('cycles and missing components/dependencies are rejected', () => { const service = createInstallRecipeService(); const cyclic = recipe([step('base-step', 'base', 'install-package', { dependsOn: ['update-step'] }), step('update-step', 'update', 'install-package', { dependsOn: ['base-step'] })]); assert.throws(() => service.validate(cyclic, packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_CYCLE'); assert.throws(() => service.validate(recipe([step('base-step', 'missing')]), packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_COMPONENT_MISSING'); assert.throws(() => service.validate(recipe([step('base-step', 'base'), step('update-step', 'update', 'install-package', { dependsOn: ['gone'] })]), packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_DEPENDENCY_MISSING'); }); test('base/update/DLC content, region, platform and destination mismatches fail closed', () => { const service = createInstallRecipeService(); const mismatched = packageSet(); mismatched.components[1].contentId = 'UP0001-CUSA99999_00-UPDATEPACKAGE001'; assert.throws(() => service.validate(recipe([step('base-step', 'base'), step('update-step', 'update')]), mismatched, { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_CONTENT_MISMATCH'); const wrongRegion = packageSet(); wrongRegion.components[1].region = 'EU'; assert.throws(() => service.validate(recipe([step('base-step', 'base'), step('update-step', 'update')]), wrongRegion, { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_REGION_MISMATCH'); assert.throws(() => service.validate(recipe([step('base-step', 'base'), step('update-step', 'update')]), packageSet(), { destinationCapabilities: { ...capabilities, canInstall: false } }), error => error.code === 'RECIPE_DESTINATION_CAPABILITY'); }); test('required components cannot be omitted while optional DLC may be absent', () => { const service = createInstallRecipeService(); const valid = service.validate(recipe([step('base-step', 'base'), step('update-step', 'update')]), packageSet(), { destinationCapabilities: capabilities }); assert.equal(valid.orderedSteps.length, 2); assert.throws(() => service.validate(recipe([step('base-step', 'base')]), packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_REQUIRED_COMPONENT'); assert.throws(() => service.validate(recipe([step('base-step', 'base', 'install-package', { required: false }), step('update-step', 'update')]), packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_REQUIRED_COMPONENT'); }); test('hostile schema fields, shell actions and raw destinations are never accepted', () => { const service = createInstallRecipeService(); const base = recipe([step('base-step', 'base'), step('update-step', 'update')]); assert.throws(() => service.validate({ ...base, command: 'rm -rf /' }, packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_SCHEMA'); assert.throws(() => service.validate({ ...base, steps: [{ ...base.steps[0], shell: 'echo owned' }, base.steps[1]] }, packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_SCHEMA'); assert.throws(() => service.validate({ ...base, steps: [{ ...base.steps[0], action: 'shell', destination: '/mnt/raw' }, base.steps[1]] }, packageSet(), { destinationCapabilities: capabilities }), error => error.code === 'RECIPE_SCHEMA'); });