diff --git a/app/services/cwmp/configuration_evaluation_state.ts b/app/services/cwmp/configuration_evaluation_state.ts index c8a0ffd..fb2b986 100644 --- a/app/services/cwmp/configuration_evaluation_state.ts +++ b/app/services/cwmp/configuration_evaluation_state.ts @@ -25,6 +25,7 @@ export class ConfigurationEvaluationState { private pendingValuesQueue: string[] = [] private paramDiffs: Array<{ name: string; value: string | number | boolean; type?: string }> = [] private initialized = false + private awaitingPhase: 'gpn_response' | 'gpv_response' | null = null constructor() { this.engine = new ConfigurationEngine() @@ -90,6 +91,42 @@ export class ConfigurationEvaluationState { } private async processNextAction(context: DeviceContext): Promise { + // --- Process pending response from previous RPC (inline, not via task callback) --- + if (this.awaitingPhase === 'gpn_response') { + this.awaitingPhase = null + if (context.message?.success && context.message.data) { + const infoList = context.message.data?.ParameterList?.ParameterInfoStruct || [] + const leafNames: string[] = infoList + .map((info: any) => { + const n = info?.Name + return typeof n === 'string' ? n : (n && typeof n._ === 'string' ? n._ : null) + }) + .filter((name: any): name is string => !!name && !name.endsWith('.')) + if (leafNames.length > 0) { + this.pendingValuesQueue.push(...leafNames) + } + logger.debug({ identifier: context.device.identifier, leafCount: leafNames.length }, '[ConfigEval] Inventory GPN response processed inline') + } else if (context.message && !context.message.success) { + // GPN failed during inventory — record failure + context.device.recordGpnFailure() + logger.debug({ identifier: context.device.identifier }, '[ConfigEval] Inventory GPN failed — skipping remaining inventory') + this.inventoryQueue = [] + } + } else if (this.awaitingPhase === 'gpv_response') { + this.awaitingPhase = null + if (context.message?.success && context.message.data) { + const list = context.message.data?.ParameterList?.ParameterValueStruct + if (Array.isArray(list) && list.length > 0) { + const normalized = list.map((pv: any) => ({ + ...pv, + Name: typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || pv?.Name, + })) + context.device.updateFromValues(normalized) + logger.debug({ identifier: context.device.identifier, count: normalized.length }, '[ConfigEval] Inventory GPV values saved to device') + } + } + } + // 1. Inventory phase 1: GetParameterNames per subtree path // Skip if device does not support GPN or circuit breaker is active if (this.inventoryQueue.length > 0 && context.device.gpnStatus !== 'unsupported' && context.device.isGpnAllowed()) { @@ -98,25 +135,9 @@ export class ConfigurationEvaluationState { const response = await context.services.deviceAction.getParameterNames( context.device, path, - async (result) => { - if (result.status === 'error') { - // GPN failed during inventory — record failure - context.device.recordGpnFailure() - } - // Collect leaf names into pendingValuesQueue for phase 2 - const infoList = result.data?.ParameterList?.ParameterInfoStruct || [] - const leafNames: string[] = infoList - .map((info: any) => { - const n = info?.Name - return typeof n === 'string' ? n : (n && typeof n._ === 'string' ? n._ : null) - }) - .filter((name: any): name is string => !!name && !name.endsWith('.')) - if (leafNames.length > 0) { - this.pendingValuesQueue.push(...leafNames) - } - return '' - } + () => Promise.resolve('') ) + this.awaitingPhase = 'gpn_response' return { response } } else if (this.inventoryQueue.length > 0) { // GPN unsupported or circuit breaker active — skip inventory @@ -131,21 +152,9 @@ export class ConfigurationEvaluationState { const response = await context.services.deviceAction.getParameterValues( context.device, leaves, - async (result) => { - if (result.status === 'success' && result.data) { - const list = result.data?.ParameterList?.ParameterValueStruct - if (Array.isArray(list) && list.length > 0) { - const normalized = list.map((pv: any) => ({ - ...pv, - Name: typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || pv?.Name, - })) - context.device.updateFromValues(normalized) - logger.debug({ identifier: context.device.identifier, count: normalized.length }, '[ConfigEval] Inventory GPV values saved to device') - } - } - return '' - } + () => Promise.resolve('') ) + this.awaitingPhase = 'gpv_response' return { response } } diff --git a/app/services/cwmp/main_state_machine.ts b/app/services/cwmp/main_state_machine.ts index bb49b95..5c7219c 100644 --- a/app/services/cwmp/main_state_machine.ts +++ b/app/services/cwmp/main_state_machine.ts @@ -59,29 +59,14 @@ class IdleState extends DeviceState { '[IdleState] GPN allowed — proceeding to discovery' ) - // GPN is supported: discover mandatory subtrees to classify parameters. - // DeviceInfo. is always included so HardwareVersion, SoftwareVersion, ModelName etc. - // are classified (writable vs read-only) from the device itself. - // If GPN is not supported, the catalog handles classification (reclassifyDeviceFromCatalog). + // Test GPN support using a single known path from the device's data model const dataModel = device.dataModel if (dataModel === 'tr-181') { - return { - nextState: new DiscoveringParametersState([ - 'Device.DeviceInfo.', - 'Device.ManagementServer.', - ]), - response: '', - } + return { nextState: new DiscoveringParametersState(['Device.ManagementServer.']), response: '' } } else if (dataModel === 'tr-098') { - return { - nextState: new DiscoveringParametersState([ - 'InternetGatewayDevice.DeviceInfo.', - 'InternetGatewayDevice.ManagementServer.', - ]), - response: '', - } + return { nextState: new DiscoveringParametersState(['InternetGatewayDevice.ManagementServer.']), response: '' } } - // Unknown data model — skip GPN, go to CriticalReadsState + // Unknown data model — skip GPN test, go to CriticalReadsState return { nextState: new CriticalReadsState(), response: '' } } } diff --git a/tests/unit/services/cwmp/config_eval_extra.spec.ts b/tests/unit/services/cwmp/config_eval_extra.spec.ts index 23dad86..21c0c06 100644 --- a/tests/unit/services/cwmp/config_eval_extra.spec.ts +++ b/tests/unit/services/cwmp/config_eval_extra.spec.ts @@ -261,7 +261,7 @@ describe('ConfigurationEvaluationState — inventory GPN phase (device supports expect(result.response).toContain('Device.ManagementServer.') }) - it('GPN callback records failure on error and extracts leaf names on success', async () => { + it('GPN response processed inline — extracts leaf names and sends GPV', async () => { const config = makeConfig({ desiredParameters: [{ name: 'Device.X', value: '1', type: 'string' }], inventory: [{ path: 'Device.ManagementServer.' }], @@ -273,31 +273,28 @@ describe('ConfigurationEvaluationState — inventory GPN phase (device supports const context = makeContext(device) const state = new ConfigurationEvaluationState() + // First call: sends GPN await state.handle(context as any) - // Get the GPN task callback from the queue - const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - expect(taskId).toBeDefined() - - // Execute callback with SUCCESS + leaf names - await context.taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, { - ParameterList: { - ParameterInfoStruct: [ - { Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: '1' }, - { Name: 'Device.ManagementServer.ConnectionRequestURL', Writable: '0' }, - { Name: 'Device.ManagementServer.', Writable: '0' }, // non-leaf, should be filtered - ], + // Second call with GPN response data → processes inline, extracts leaves, sends GPV + context.message = { + success: true, + data: { + ParameterList: { + ParameterInfoStruct: [ + { Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: '1' }, + { Name: 'Device.ManagementServer.ConnectionRequestURL', Writable: '0' }, + { Name: 'Device.ManagementServer.', Writable: '0' }, // non-leaf, filtered + ], + }, }, - }) - - // Now handle again — should enter phase 2 (GPV for discovered leaves) + } const result2 = await state.handle(context as any) expect(result2.response).toContain('GetParameterValues') expect(result2.response).toContain('Device.ManagementServer.PeriodicInformInterval') }) - it('GPN callback records failure when result is error', async () => { + it('GPN failure processed inline — records failure and skips inventory', async () => { const config = makeConfig({ desiredParameters: [{ name: 'Device.X', value: '1', type: 'string' }], inventory: [{ path: 'Device.ManagementServer.' }], @@ -311,14 +308,11 @@ describe('ConfigurationEvaluationState — inventory GPN phase (device supports const state = new ConfigurationEvaluationState() await state.handle(context as any) - // Get the GPN task callback - const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] + // GPN fault response + context.message = { success: false, data: { FaultCode: 9001 } } + await state.handle(context as any) - // Execute callback with ERROR - await context.taskQueue.executeTaskCallback(taskId!, ResultStatus.Error, { FaultCode: 9001 }) - - // Device should have recorded GPN failure (may be called by both executor and ConfigEval callbacks) + // Device should have recorded GPN failure expect(device.gpnCircuitBreaker.failureCount).toBeGreaterThanOrEqual(1) }) @@ -418,7 +412,7 @@ describe('ConfigurationEvaluationState — phase 2 GPV callback and SPV callback vi.clearAllMocks() }) - it('inventory phase 2 GPV callback is invoked', async () => { + it('inventory phase 2 GPV response processed inline', async () => { const config = makeConfig({ desiredParameters: [{ name: 'Device.X', value: '1', type: 'string' }], inventory: [{ path: 'Device.ManagementServer.' }], @@ -433,22 +427,23 @@ describe('ConfigurationEvaluationState — phase 2 GPV callback and SPV callback // First call: sends GPN await state.handle(context as any) - // Execute GPN callback with leaf names to populate pendingValuesQueue - const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map - const gpnTaskId = Array.from(taskCallbacks.keys())[0] - await context.taskQueue.executeTaskCallback(gpnTaskId!, ResultStatus.Success, { - ParameterList: { ParameterInfoStruct: [{ Name: 'Device.ManagementServer.X', Writable: '1' }] }, - }) - - // Second call: sends GPV for discovered leaves (phase 2) + // Second call: GPN response → extracts leaves → sends GPV + context.message = { + success: true, + data: { ParameterList: { ParameterInfoStruct: [{ Name: 'Device.ManagementServer.X', Writable: '1' }] } }, + } const result2 = await state.handle(context as any) expect(result2.response).toContain('GetParameterValues') - // Execute the GPV callback (covers line ~135 `() => Promise.resolve('')`) - const gpvTaskId = Array.from(taskCallbacks.keys()).pop() - await context.taskQueue.executeTaskCallback(gpvTaskId!, ResultStatus.Success, { - ParameterList: { ParameterValueStruct: [] }, - }) + // Third call: GPV response → updates device → proceeds to SPV + context.message = { + success: true, + data: { ParameterList: { ParameterValueStruct: [{ Name: 'Device.ManagementServer.X', Value: { _: '42', '$xsi:type': 'xsd:string' } }] } }, + } + const result3 = await state.handle(context as any) + // Should now apply diffs (SPV) since Device.X doesn't match desired '1' + expect(result3.response || result3.nextState).toBeDefined() + }) }) it('SPV callback is invoked after applying diffs', async () => { diff --git a/tests/unit/services/cwmp/configuration_evaluation_state.spec.ts b/tests/unit/services/cwmp/configuration_evaluation_state.spec.ts index 3a2f50a..4127661 100644 --- a/tests/unit/services/cwmp/configuration_evaluation_state.spec.ts +++ b/tests/unit/services/cwmp/configuration_evaluation_state.spec.ts @@ -97,16 +97,16 @@ describe('ConfigurationEvaluationState', () => { }) describe('ConfigurationEvaluationState - full happy path', () => { - it('inventory GPN → GPN → SPV diffs → compliance-update', async () => { + it('inventory GPN → GPV → SPV diffs → compliance-update', async () => { const state = new ConfigurationEvaluationState() const context = makeContext('supported') - // Step 1: First call → GPN for Device.DeviceInfo. + // Step 1: First call → GPN for inventory path (Device.DeviceInfo.) const r1 = await state.handle(context) expect(r1.response).toContain('GetParameterNames') expect(r1.response).toContain('Device.DeviceInfo.') - // Step 2: GPN response → next GPN for Device.ManagementServer. + // Step 2: GPN response for DeviceInfo → sends next GPN for ManagementServer. context.message = { success: true, data: { @@ -119,17 +119,32 @@ describe('ConfigurationEvaluationState - full happy path', () => { expect(r2.response).toContain('GetParameterNames') expect(r2.response).toContain('Device.ManagementServer.') - // Step 3: Second GPN response → no more inventory → SPV diffs - context.message = { success: true, data: { ParameterList: { ParameterInfoStruct: [] } } } + // Step 3: GPN response for ManagementServer → extract leaves → GPV + context.message = { + success: true, + data: { + ParameterList: { + ParameterInfoStruct: [{ Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: '1' }], + }, + }, + } const r3 = await state.handle(context) - expect(r3.response).toContain('SetParameterValues') - expect(r3.response).toContain('Device.X') + expect(r3.response).toContain('GetParameterValues') - // Step 4: SPV response → no firmware → transitions to compliance-update - context.message = { success: true, data: { Status: 0 } } + // Step 4: GPV response → values saved → SPV diffs + context.message = { + success: true, + data: { ParameterList: { ParameterValueStruct: [{ Name: 'Device.ManagementServer.PeriodicInformInterval', Value: { _: '60' } }] } }, + } const r4 = await state.handle(context) - expect(r4.nextState).toBeDefined() - expect(r4.nextState!.name).toBe('compliance-update') + expect(r4.response).toContain('SetParameterValues') + expect(r4.response).toContain('Device.X') + + // Step 5: SPV response → no firmware → transitions to compliance-update + context.message = { success: true, data: { Status: 0 } } + const r5 = await state.handle(context) + expect(r5.nextState).toBeDefined() + expect(r5.nextState!.name).toBe('compliance-update') }) }) diff --git a/tests/unit/services/cwmp/gpn_circuit_breaker.spec.ts b/tests/unit/services/cwmp/gpn_circuit_breaker.spec.ts index 3ef3897..1c6bfc4 100644 --- a/tests/unit/services/cwmp/gpn_circuit_breaker.spec.ts +++ b/tests/unit/services/cwmp/gpn_circuit_breaker.spec.ts @@ -111,27 +111,11 @@ describe('GPN Circuit Breaker — DiscoveringParametersState', () => { const taskQueue = new CWMPTaskQueue() const sm = new MainStateMachine(device, 'session-1', taskQueue) - // Start: IdleState → DiscoveringParametersState → sends GPN for Device.DeviceInfo. + // Start: IdleState → DiscoveringParametersState → sends GPN for Device.ManagementServer. await sm.start() - // Simulate a successful GPN response for DeviceInfo + // Simulate a successful GPN response → transitions to critical-reads const gpnResponse = { - ParameterList: { - ParameterInfoStruct: [ - { Name: 'Device.DeviceInfo.', Writable: false }, - { Name: 'Device.DeviceInfo.HardwareVersion', Writable: false }, - { Name: 'Device.DeviceInfo.SoftwareVersion', Writable: false }, - { Name: 'Device.DeviceInfo.ModelName', Writable: false }, - { Name: 'Device.DeviceInfo.ProvisioningCode', Writable: true }, - ], - }, - } - // First GPN response → still discovering (ManagementServer. still in queue) - await sm.handleResponse(true, gpnResponse) - expect(sm.getCurrentStateName()).toBe('discovering-parameters') - - // Second GPN response (for Device.ManagementServer.) → transitions to critical-reads - const gpnResponse2 = { ParameterList: { ParameterInfoStruct: [ { Name: 'Device.ManagementServer.', Writable: false }, @@ -140,26 +124,16 @@ describe('GPN Circuit Breaker — DiscoveringParametersState', () => { ], }, } - await sm.handleResponse(true, gpnResponse2) + await sm.handleResponse(true, gpnResponse) - // gpnStatus should be 'supported' + // Should transition to critical-reads after single GPN + expect(sm.getCurrentStateName()).toBe('critical-reads') + // Device should be marked as GPN supported expect(device.gpnStatus).toBe('supported') - - // Writable params should be stored as parameters - const periodicInterval = device.getParameter('Device.ManagementServer.PeriodicInformInterval') - expect(periodicInterval).not.toBeNull() - - // Read-only params should be stored as properties - const connUrl = device.getProperty('Device.ManagementServer.ConnectionRequestURL') - expect(connUrl).not.toBeNull() - - // Writable DeviceInfo param should be parameter - const provCode = device.getParameter('Device.DeviceInfo.ProvisioningCode') - expect(provCode).not.toBeNull() - - // Object paths (ending with dot) should NOT be stored - const obj = device.getParameter('Device.DeviceInfo.') - expect(obj).toBeNull() + }) + }, + } + // (remaining tests continue below) }) it('DiscoveringParametersState sets gpnStatus supported even with empty param list', async () => { diff --git a/tests/unit/services/cwmp/main_state_machine_extended.spec.ts b/tests/unit/services/cwmp/main_state_machine_extended.spec.ts index 4bff9d6..28c06c3 100644 --- a/tests/unit/services/cwmp/main_state_machine_extended.spec.ts +++ b/tests/unit/services/cwmp/main_state_machine_extended.spec.ts @@ -44,23 +44,23 @@ describe('MainStateMachine', () => { it('start triggers state execution and returns response', async () => { const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) const response = await sm.start() - // idle → discovering-parameters → sends GPN for Device.DeviceInfo. (first mandatory subtree) + // idle → discovering-parameters → sends GPN for Device.ManagementServer. (single test path) expect(response).toContain('GetParameterNames') - expect(response).toContain('Device.DeviceInfo.') + expect(response).toContain('Device.ManagementServer.') expect(sm.getCurrentStateName()).toBe('discovering-parameters') }) it('handleResponse advances the state machine', async () => { const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) await sm.start() - // Simulate GPN success response for DeviceInfo (first path) + // Simulate GPN success response → transitions to critical-reads (single path queue) await sm.handleResponse(true, { ParameterList: { - ParameterInfoStruct: [{ Name: 'Device.DeviceInfo.HardwareVersion', Writable: '0' }], + ParameterInfoStruct: [{ Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: '1' }], }, }) - // Queue still has ManagementServer. → still in discovering-parameters - expect(sm.getCurrentStateName()).toBe('discovering-parameters') + // Only one path in queue → transitions to critical-reads + expect(sm.getCurrentStateName()).toBe('critical-reads') }) it('with unknown data model, skips to critical-reads', async () => { @@ -73,7 +73,7 @@ describe('MainStateMachine', () => { it('with tr-098, sends GPN for InternetGatewayDevice.ManagementServer.', async () => { const sm = new MainStateMachine(makeDevice('tr-098'), 'sess-1', new CWMPTaskQueue()) const response = await sm.start() - expect(response).toContain('InternetGatewayDevice.DeviceInfo.') + expect(response).toContain('InternetGatewayDevice.ManagementServer.') }) it('GPN failure sets gpnStatus unsupported and continues', async () => { @@ -87,15 +87,11 @@ describe('MainStateMachine', () => { it('full lifecycle: idle → discovering → critical-reads → config-eval → compliance → executing → operational', async () => { const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) - // Step 1: start → GPN + // Step 1: start → GPN (single path test) await sm.start() expect(sm.getCurrentStateName()).toBe('discovering-parameters') - // Step 2a: first GPN response (DeviceInfo.) → still discovering - await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } }) - expect(sm.getCurrentStateName()).toBe('discovering-parameters') - - // Step 2b: second GPN response (ManagementServer.) → critical-reads + // Step 2: GPN response → transitions to critical-reads await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } }) expect(sm.getCurrentStateName()).toBe('critical-reads') diff --git a/tests/unit/services/cwmp/main_state_machine_extra.spec.ts b/tests/unit/services/cwmp/main_state_machine_extra.spec.ts index 358b07b..6ffa5ca 100644 --- a/tests/unit/services/cwmp/main_state_machine_extra.spec.ts +++ b/tests/unit/services/cwmp/main_state_machine_extra.spec.ts @@ -67,7 +67,7 @@ describe('MainStateMachine — additional coverage (DiscoveringParameters, GPN f const response = await sm.start() expect(response).toContain('GetParameterNames') - expect(response).toContain('InternetGatewayDevice.DeviceInfo.') + expect(response).toContain('InternetGatewayDevice.ManagementServer.') }) it('skips GPN for unknown dataModel and goes to critical-reads', async () => { @@ -83,18 +83,16 @@ describe('MainStateMachine — additional coverage (DiscoveringParameters, GPN f }) it('DiscoveringParametersState handles GPN success and transitions to CriticalReads', async () => { - const device = makeDevice('unknown') // tr-181 → DeviceInfo. + ManagementServer. in queue + const device = makeDevice('unknown') // gpnStatus=unknown → will attempt GPN + device.setGpnStatus('unknown') + device.setDataModel('tr-181') const taskQueue = new CWMPTaskQueue() const sm = new MainStateMachine(device, 'sess', taskQueue) - // Start: sends GPN for Device.DeviceInfo. + // Start: sends GPN for Device.ManagementServer. (single test path) await sm.start() - // First GPN response (DeviceInfo) → still in discovering-parameters (ManagementServer. pending) - await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } }) - expect(sm.getCurrentStateName()).toBe('discovering-parameters') - - // Second GPN response (ManagementServer) → transitions to critical-reads and sends GPV + // GPN response → transitions to critical-reads and sends GPV const response = await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } }) expect(response).toContain('GetParameterValues') expect(sm.getCurrentStateName()).toBe('critical-reads')