diff --git a/app/services/cwmp/main_state_machine.ts b/app/services/cwmp/main_state_machine.ts index 747005b..2c408dc 100644 --- a/app/services/cwmp/main_state_machine.ts +++ b/app/services/cwmp/main_state_machine.ts @@ -457,8 +457,10 @@ export class OperationalState extends DeviceState { async handle(context: DeviceContext): Promise { const { device } = context - // Consolidated reclassification (Phase 11) - reclassifyDeviceFromCatalog(device) + // Consolidated reclassification — only if not already classified + if (device.gpnStatus !== 'supported' && device.getParameters().length === 0) { + reclassifyDeviceFromCatalog(device) + } // Persist device with operational status device.operationalStatus = 'operational' diff --git a/tests/unit/services/cwmp/two_session_scenario.spec.ts b/tests/unit/services/cwmp/two_session_scenario.spec.ts new file mode 100644 index 0000000..7a46e7d --- /dev/null +++ b/tests/unit/services/cwmp/two_session_scenario.spec.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock logger +vi.mock('@adonisjs/core/services/logger', () => ({ + default: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +// Mock DeviceModel +vi.mock('../../../../app/models/device.js', () => ({ + default: { + createOrUpdate: vi.fn().mockResolvedValue({}), + findByDeviceId: vi.fn().mockResolvedValue(null), + updateOne: vi.fn().mockResolvedValue({}), + }, +})) + +// Mock CommandModel +vi.mock('../../../../app/models/command.js', () => ({ + default: { + getCollection: vi.fn().mockReturnValue({ + find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), + countDocuments: vi.fn().mockResolvedValue(0), + }), + updateCommand: vi.fn().mockResolvedValue({}), + }, +})) + +// Mock ConfigurationModel +vi.mock('#models/configuration', () => ({ + default: { + find: vi.fn().mockResolvedValue([{ + identifier: 'BM632w-standard', + name: 'BM632w Standard', + version: '1.0.1', + priority: 1, + policy: 'maintain', + enabled: true, + criteria: { manufacturer: 'Huawei*', productClass: 'BM632w' }, + inventory: [{ path: 'InternetGatewayDevice.ManagementServer.' }], + desiredParameters: [ + { name: 'InternetGatewayDevice.ManagementServer.PeriodicInformInterval', value: '300', type: 'unsignedInt' }, + ], + firmwareTarget: null, + schedule: null, + tags: [], + }]), + findOne: vi.fn().mockResolvedValue(null), + }, +})) + +// Mock FirmwareCatalogService +vi.mock('../../../../app/services/firmware_catalog_service.js', () => ({ + default: class { async resolveUpgradePath() { return [] } }, + FirmwareCatalogService: class { async resolveUpgradePath() { return [] } }, +})) + +// Mock schedule_window +vi.mock('../../../../app/utils/schedule_window.js', () => ({ + isWithinSchedule: vi.fn().mockReturnValue(true), +})) + +// Mock reclassify +vi.mock('../../../../app/services/parameter_writability.js', () => ({ + reclassifyDeviceFromCatalog: vi.fn(), +})) + +import { MainStateMachine } from '../../../../app/services/cwmp/main_state_machine.js' +import { Device } from '../../../../app/domain/device/device.js' +import { DeviceId } from '../../../../app/domain/device/device_id.js' +import { DeviceParameter, DeviceProperty } from '../../../../app/domain/device/device_parameter.js' +import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js' +import { ParameterType } from '../../../../app/domain/shared/types.js' +import { reclassifyDeviceFromCatalog } from '../../../../app/services/parameter_writability.js' +import { buildProvisioningCode } from '../../../../app/utils/provisioning_code.js' +import logger from '@adonisjs/core/services/logger' + +/** + * Simulates two full Inform sessions from the same BM632w device. + * + * Session 1: Device is NEW (not in DB) + * → GPN test on Inform paths → classify → Critical reads → ConfigEval (GPN+GPV+SPV) → ProvCode → Done + * + * Session 2: Device is KNOWN (already classified, already compliant) + * → Skip GPN (already classified) → Critical reads → ConfigEval (skip) → Done + */ +describe('Two-session scenario: same device, first contact then reconnect', () => { + let device: Device + + beforeEach(() => { + vi.clearAllMocks() + }) + + function makeNewDevice(): Device { + const d = new Device(new DeviceId('Huawei Technologies Co., Ltd.', '202BC1', 'BM632w', '000000')) + d.setDataModel('tr-098') + // Simulate Inform parameters (stored by handleInform before state machine starts) + d.updateFromValues([ + { Name: 'InternetGatewayDevice.DeviceInfo.HardwareVersion', Value: { _: '40501' } }, + { Name: 'InternetGatewayDevice.DeviceInfo.SoftwareVersion', Value: { _: 'V100R001' } }, + { Name: 'InternetGatewayDevice.ManagementServer.ConnectionRequestURL', Value: { _: 'http://172.20.0.5:37547/' } }, + { Name: 'InternetGatewayDevice.ManagementServer.PeriodicInformInterval', Value: { _: '60' } }, + ]) + return d + } + + it('SESSION 1: new device — full GPN + classify + critical reads + config eval + SPV + provCode', async () => { + device = makeNewDevice() + // gpnStatus = 'unknown', no parameters (only properties from Inform) → GPN test will run + expect(device.gpnStatus).toBe('unknown') + expect(device.getParameters().length).toBe(0) // all stored as properties initially + + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-1', taskQueue) + + console.log('\n=== SESSION 1: New device (first contact) ===') + + // Step 1: start → IdleState extracts Inform paths → sends GPN + const step1 = await sm.start() + console.log(`1. [${sm.getCurrentStateName()}] → GPN sent`) + expect(step1).toContain('GetParameterNames') + expect(sm.getCurrentStateName()).toBe('discovering-parameters') + + // Step 2: First GPN response (DeviceInfo.) → still discovering (ManagementServer. pending) + await sm.handleResponse(true, { + ParameterList: { + ParameterInfoStruct: [ + { Name: 'InternetGatewayDevice.DeviceInfo.HardwareVersion', Writable: '0' }, + { Name: 'InternetGatewayDevice.DeviceInfo.SoftwareVersion', Writable: '0' }, + ], + }, + }) + console.log(`2. [${sm.getCurrentStateName()}] → First GPN response (DeviceInfo.), next GPN pending`) + expect(sm.getCurrentStateName()).toBe('discovering-parameters') + + // Step 3: Second GPN response (ManagementServer.) → classify → transition to critical-reads → GPV + const step3 = await sm.handleResponse(true, { + ParameterList: { + ParameterInfoStruct: [ + { Name: 'InternetGatewayDevice.ManagementServer.ConnectionRequestURL', Writable: '0' }, + { Name: 'InternetGatewayDevice.ManagementServer.PeriodicInformInterval', Writable: '1' }, + ], + }, + }) + console.log(`3. [${sm.getCurrentStateName()}] → Second GPN response (ManagementServer.), GPV (critical reads) sent`) + expect(device.gpnStatus).toBe('supported') + expect(step3).toContain('GetParameterValues') + expect(sm.getCurrentStateName()).toBe('critical-reads') + + // Step 4: Critical reads GPV response → update device → transition to config-eval + const step4 = await sm.handleResponse(true, { + ParameterList: { + ParameterValueStruct: [ + { Name: 'InternetGatewayDevice.DeviceInfo.HardwareVersion', Value: { _: '40501' } }, + { Name: 'InternetGatewayDevice.DeviceInfo.SoftwareVersion', Value: { _: 'V100R001' } }, + { Name: 'InternetGatewayDevice.DeviceInfo.ModelName', Value: { _: 'BM632w' } }, + { Name: 'InternetGatewayDevice.ManagementServer.ConnectionRequestURL', Value: { _: 'http://172.20.0.5:37547/' } }, + ], + }, + }) + console.log(`4. [${sm.getCurrentStateName()}] → Critical reads processed, ConfigEval started`) + expect(sm.getCurrentStateName()).toBe('configuration-evaluation') + + // Step 5: ConfigEval sends inventory GPN + expect(step4).toContain('GetParameterNames') + console.log(`5. [${sm.getCurrentStateName()}] → Inventory GPN sent`) + + // Step 6: Inventory GPN response → discover leaves → sends GPV + const step6 = await sm.handleResponse(true, { + ParameterList: { + ParameterInfoStruct: [ + { Name: 'InternetGatewayDevice.ManagementServer.PeriodicInformInterval', Writable: '1' }, + { Name: 'InternetGatewayDevice.ManagementServer.ConnectionRequestURL', Writable: '0' }, + ], + }, + }) + console.log(`6. [${sm.getCurrentStateName()}] → Inventory GPN response → sends GPV for leaves`) + expect(step6).toContain('GetParameterValues') + + // Step 7: Inventory GPV response → update values → sends SPV for diff + const step7 = await sm.handleResponse(true, { + ParameterList: { + ParameterValueStruct: [ + { Name: 'InternetGatewayDevice.ManagementServer.PeriodicInformInterval', Value: { _: '60' } }, + { Name: 'InternetGatewayDevice.ManagementServer.ConnectionRequestURL', Value: { _: 'http://172.20.0.5:37547/' } }, + ], + }, + }) + console.log(`7. [${sm.getCurrentStateName()}] → GPV values saved → SPV sent (PeriodicInformInterval 60→300)`) + expect(step7).toContain('SetParameterValues') + expect(step7).toContain('300') + + // Step 8: SPV response → transition to compliance-update → sends ProvisioningCode + const step8 = await sm.handleResponse(true, { Status: '0' }) + console.log(`8. [${sm.getCurrentStateName()}] → SPV success → ProvisioningCode sent`) + expect(step8).toContain('SetParameterValues') + expect(step8).toContain('ProvisioningCode') + + // Step 9: ProvCode response → executing-commands → operational → done + const step9 = await sm.handleResponse(true, { Status: '0' }) + console.log(`9. [${sm.getCurrentStateName()}] → ProvCode done → session complete`) + expect(sm.isCompleted()).toBe(true) + expect(sm.getCurrentStateName()).toBe('operational') + + console.log('\n✅ Session 1 complete: device classified + configured') + console.log(` gpnStatus: ${device.gpnStatus}`) + console.log(` parameters: ${device.getParameters().length}`) + console.log(` properties: ${device.getProperties().length}`) + }) + + it('SESSION 2: known device (already classified + compliant) — minimal session', async () => { + // Simulate a device loaded from DB after session 1 + device = makeNewDevice() + device.setGpnStatus('supported') + // Simulate that organizeParameters was called in session 1: writable params are parameters + device.setParameter(new DeviceParameter( + 'InternetGatewayDevice.ManagementServer.PeriodicInformInterval', '300', ParameterType.UNSIGNED_INT + )) + // ProvisioningCode already set from session 1 + const config = { identifier: 'BM632w-standard', version: '1.0.1', desiredParameters: [{ name: 'InternetGatewayDevice.ManagementServer.PeriodicInformInterval', value: '300', type: 'unsignedInt' }], firmwareTarget: null } + const provCode = buildProvisioningCode(config as any) + device.setProperty(new DeviceProperty('InternetGatewayDevice.DeviceInfo.ProvisioningCode', provCode, ParameterType.STRING)) + + expect(device.gpnStatus).toBe('supported') + expect(device.getParameters().length).toBeGreaterThan(0) + + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-2', taskQueue) + + console.log('\n=== SESSION 2: Known device (already classified + compliant) ===') + + // Step 1: start → IdleState sees gpnStatus=supported + has parameters → SKIP GPN → critical reads GPV + const step1 = await sm.start() + console.log(`1. [${sm.getCurrentStateName()}] → GPN SKIPPED (already classified) → GPV (critical reads) sent`) + expect(step1).toContain('GetParameterValues') + expect(step1).not.toContain('GetParameterNames') + expect(sm.getCurrentStateName()).toBe('critical-reads') + + // Step 2: Critical reads GPV response → config eval + const step2 = await sm.handleResponse(true, { + ParameterList: { + ParameterValueStruct: [ + { Name: 'InternetGatewayDevice.DeviceInfo.HardwareVersion', Value: { _: '40501' } }, + { Name: 'InternetGatewayDevice.DeviceInfo.SoftwareVersion', Value: { _: 'V100R001' } }, + { Name: 'InternetGatewayDevice.DeviceInfo.ModelName', Value: { _: 'BM632w' } }, + { Name: 'InternetGatewayDevice.ManagementServer.ConnectionRequestURL', Value: { _: 'http://172.20.0.5:37547/' } }, + ], + }, + }) + console.log(`2. [${sm.getCurrentStateName()}] → Critical reads done → ConfigEval: already compliant, no SPV needed`) + + // ConfigEval should find isAlreadyCompliant=true (value matches, provCode matches) + // → skips inventory, skips SPV, skips provCode → goes to operational + expect(sm.isCompleted()).toBe(true) + expect(sm.getCurrentStateName()).toBe('operational') + + console.log(`3. [${sm.getCurrentStateName()}] → Session complete (no GPN, no SPV, no ProvCode)`) + + // Verify reclassifyDeviceFromCatalog was NOT called (device already classified) + expect(vi.mocked(reclassifyDeviceFromCatalog)).not.toHaveBeenCalled() + + console.log('\n✅ Session 2 complete: minimal session (GPV only)') + console.log(` Total RPCs: 1 (GetParameterValues for critical reads)`) + console.log(` Skipped: GPN test, inventory, SPV, ProvisioningCode`) + }) +})