diff --git a/app/controllers/cwmp_controller.ts b/app/controllers/cwmp_controller.ts index bbc560b..b82ae0e 100644 --- a/app/controllers/cwmp_controller.ts +++ b/app/controllers/cwmp_controller.ts @@ -131,6 +131,10 @@ export default class CwmpController { const paramValues = informData?.ParameterList?.ParameterValueStruct || [] if (paramValues.length > 0) { device.updateFromValues(paramValues) + // Some writable parameters arrive in the Inform before GPN has classified them. + // updateFromValues falls back to storing unknowns as read-only properties. + // Reclassify known-writable Inform parameters as parameters (writable). + device.reclassifyInformWritables(paramValues) } device.setTmpParamValues(paramValues) diff --git a/app/domain/device/device.ts b/app/domain/device/device.ts index 64738f3..a7b32c3 100644 --- a/app/domain/device/device.ts +++ b/app/domain/device/device.ts @@ -251,6 +251,37 @@ export class Device { } } + public removeProperty(name: string): void { + this._properties.delete(name) + } + + /** + * Parameters that arrive in the Inform payload are writable by definition + * (the CPE reports them because the ACS is supposed to manage them). + * updateFromValues() defaults unknowns to properties (read-only), so we + * promote the known-writable Inform params back to parameters here. + * + * Known writable Inform parameters (TR-098 and TR-181): + * - *.ProvisioningCode + * - *.ParameterKey + */ + public reclassifyInformWritables(paramValues: any[]): void { + const WRITABLE_PATTERNS = [ + /(^|\.)ProvisioningCode$/, + /(^|\.)ParameterKey$/, + ] + for (const pv of paramValues || []) { + const name = typeof pv?.Name === 'string' ? pv.Name : pv?.Name?._ || null + if (!name) continue + if (!WRITABLE_PATTERNS.some((re) => re.test(name))) continue + // Only act if it ended up as a property (wrong classification) + if (!this._properties.has(name)) continue + const prop = this._properties.get(name)! + this._properties.delete(name) + this._parameters.set(name, new DeviceParameter(name, prop.value, prop.type)) + } + } + public getConnectionRequestURL(): URL | null { for (const property of this._properties.values()) { const url = property.getConnectionRequestURL() diff --git a/app/services/cwmp/configuration_evaluation_state.ts b/app/services/cwmp/configuration_evaluation_state.ts index 20772bb..c8a0ffd 100644 --- a/app/services/cwmp/configuration_evaluation_state.ts +++ b/app/services/cwmp/configuration_evaluation_state.ts @@ -131,7 +131,20 @@ export class ConfigurationEvaluationState { const response = await context.services.deviceAction.getParameterValues( context.device, leaves, - () => Promise.resolve('') + 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 '' + } ) return { response } } diff --git a/app/services/cwmp/device_action_executor.ts b/app/services/cwmp/device_action_executor.ts index 2604009..b8e16d2 100644 --- a/app/services/cwmp/device_action_executor.ts +++ b/app/services/cwmp/device_action_executor.ts @@ -1,4 +1,5 @@ import { Device } from '../../domain/device/device.js' +import { DeviceParameter } from '../../domain/device/device_parameter.js' import { ParameterType } from '../../domain/shared/types.js' import { CWMPTaskQueue, ResultStatus, TaskResult } from './task_queue.js' import DeviceModel from '../../models/device.js' @@ -401,7 +402,10 @@ export class DeviceActionExecutor { { parameterValueList: pvStructs, parameterKey }, async (result: TaskResult) => { if (result.status === ResultStatus.Success) { - device.updateFromValues(pvStructs as any) + // ProvisioningCode must always be stored as a writable parameter. + // If it was previously classified as a read-only property (wrong), remove it first. + device.removeProperty(provisioningPath) + device.setParameter(new DeviceParameter(provisioningPath, device.tmpPC, ParameterType.STRING)) await this.persistDevice(device) device.tmpPC = '' return await callback(true) diff --git a/app/services/cwmp/main_state_machine.ts b/app/services/cwmp/main_state_machine.ts index eab9155..bb49b95 100644 --- a/app/services/cwmp/main_state_machine.ts +++ b/app/services/cwmp/main_state_machine.ts @@ -59,14 +59,29 @@ class IdleState extends DeviceState { '[IdleState] GPN allowed — proceeding to discovery' ) - // Test GPN support using the ManagementServer object path + // 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). const dataModel = device.dataModel if (dataModel === 'tr-181') { - return { nextState: new DiscoveringParametersState(['Device.ManagementServer.']), response: '' } + return { + nextState: new DiscoveringParametersState([ + 'Device.DeviceInfo.', + 'Device.ManagementServer.', + ]), + response: '', + } } else if (dataModel === 'tr-098') { - return { nextState: new DiscoveringParametersState(['InternetGatewayDevice.ManagementServer.']), response: '' } + return { + nextState: new DiscoveringParametersState([ + 'InternetGatewayDevice.DeviceInfo.', + 'InternetGatewayDevice.ManagementServer.', + ]), + response: '', + } } - // Unknown data model — skip GPN test, go to CriticalReadsState + // Unknown data model — skip GPN, go to CriticalReadsState return { nextState: new CriticalReadsState(), response: '' } } } @@ -130,7 +145,21 @@ class DiscoveringParametersState extends DeviceState { return { nextState: new CriticalReadsState(), response: '' } } - // Process response and continue + // GPN success — process the response + if (context.message?.success && context.message.data) { + try { + context.device.organizeParameters(context.message.data, new Date()) + context.device.setGpnStatus('supported') + logger.info( + { deviceId: context.device.identifier, paramCount: context.device.getParameters().length }, + '[DiscoveringParameters] GPN response processed' + ) + } catch (e: any) { + logger.error({ error: e?.message }, '[DiscoveringParameters] Error processing GPN response') + } + } + + // Continue with next path in queue if any if (this.parameterQueue.length > 0) { return { response: await this.processNext(context) } } diff --git a/app/services/cwmp/state_machine_manager.ts b/app/services/cwmp/state_machine_manager.ts index be7dd55..4e38052 100644 --- a/app/services/cwmp/state_machine_manager.ts +++ b/app/services/cwmp/state_machine_manager.ts @@ -343,8 +343,13 @@ export class StateMachineManager extends EventEmitter { return '' } - // If state machine produced a response, send it + // If state machine produced a response, send it. + // Also track the task ID that was just created (by addTask inside a state handler) + // so that handleCWMPResponse can execute its callback when the CPE replies. if (response && response !== '') { + if (!machineInstance.lastTaskId) { + machineInstance.lastTaskId = this.taskQueue.getLastCreatedTaskId(machineInstance.deviceId) + } return response } @@ -409,6 +414,12 @@ export class StateMachineManager extends EventEmitter { return '' } + // If the state machine produced a new request (e.g. next inventory GPN, next SPV), + // track the task ID so the next handleCWMPResponse can fire its callback. + if (response && response !== '' && !machineInstance.lastTaskId) { + machineInstance.lastTaskId = this.taskQueue.getLastCreatedTaskId(machineInstance.deviceId) + } + return response } catch (error) { logger.error(`Error handling CWMP response: ${error}`) @@ -434,6 +445,8 @@ export class StateMachineManager extends EventEmitter { const deviceKey = JSON.stringify(machine.deviceId) this.deviceSessions.delete(deviceKey) this.sessionMachines.delete(machine.session) + // Drain any leftover tasks so they don't bleed into the next session + this.taskQueue.clearPendingTasksForDevice(machine.deviceId) } private failMachine(machine: StateMachineInstance, error: string): void { diff --git a/app/services/cwmp/task_queue.ts b/app/services/cwmp/task_queue.ts index 85db8a8..6fbb8e9 100644 --- a/app/services/cwmp/task_queue.ts +++ b/app/services/cwmp/task_queue.ts @@ -86,6 +86,8 @@ export class CWMPTaskQueue extends EventEmitter { private tasks: Map = new Map() private deviceQueues: Map = new Map() private taskCallbacks: Map Promise> = new Map() + /** Tracks the most recently created task per device (set by addTask) */ + private lastCreatedTaskIdByDevice: Map = new Map() /** * Adds a task with callback (legacy-compatible API used by DeviceActionExecutor) @@ -108,6 +110,9 @@ export class CWMPTaskQueue extends EventEmitter { cwmpTask.status = TaskStatus.SENT } + // Track the most recent task created for this device so callers can retrieve its ID + this.lastCreatedTaskIdByDevice.set(JSON.stringify(deviceId), cwmpTask.id) + // Generate CWMP envelope const builder = CWMPTaskQueue.task_map[taskType] if (typeof builder !== 'function') { @@ -217,20 +222,28 @@ export class CWMPTaskQueue extends EventEmitter { return '' } + /** + * Returns the ID of the most recently created task for a device (via addTask). + * Used by the state machine manager to correlate responses with in-flight tasks + * that were created directly by state handlers (not served from the external queue). + */ + getLastCreatedTaskId(deviceId: DeviceId): string | undefined { + return this.lastCreatedTaskIdByDevice.get(JSON.stringify(deviceId)) + } + clearPendingTasksForDevice(deviceId: DeviceId): void { const deviceKey = JSON.stringify(deviceId) const queue = this.deviceQueues.get(deviceKey) || [] - const keep: string[] = [] + // Clear ALL tasks (PENDING and SENT) — after session completion, no task + // from the previous session should bleed into the next one. Tasks that were + // already SENT (created by state-machine states via addTask()) are ghost + // entries that would otherwise be served to the next session. for (const taskId of queue) { - const t = this.tasks.get(taskId) - if (t && t.status === TaskStatus.SENT) { - keep.push(taskId) - continue - } this.tasks.delete(taskId) this.taskCallbacks.delete(taskId) } - this.deviceQueues.set(deviceKey, keep) + this.deviceQueues.set(deviceKey, []) + this.lastCreatedTaskIdByDevice.delete(deviceKey) } private generateTaskId(deviceId: DeviceId, type: string): string { diff --git a/tests/unit/domain/device.spec.ts b/tests/unit/domain/device.spec.ts index 990c504..454143e 100644 --- a/tests/unit/domain/device.spec.ts +++ b/tests/unit/domain/device.spec.ts @@ -226,3 +226,67 @@ describe('Device', () => { expect(d.getParameter('Device.X')!.value).toBe('new') }) }) + +describe('Device.reclassifyInformWritables', () => { + it('promotes ProvisioningCode from property to parameter', () => { + const d = makeDevice() + // Simulate what updateFromValues does on Inform: stores as property (unknown writability) + d.setProperty(new DeviceProperty('InternetGatewayDevice.DeviceInfo.ProvisioningCode', 'abc')) + + d.reclassifyInformWritables([ + { Name: 'InternetGatewayDevice.DeviceInfo.ProvisioningCode', Value: { _: 'abc' } }, + ]) + + expect(d.getParameter('InternetGatewayDevice.DeviceInfo.ProvisioningCode')).not.toBeNull() + expect(d.getProperty('InternetGatewayDevice.DeviceInfo.ProvisioningCode')).toBeNull() + }) + + it('promotes ParameterKey from property to parameter', () => { + const d = makeDevice() + d.setProperty(new DeviceProperty('InternetGatewayDevice.ManagementServer.ParameterKey', '')) + + d.reclassifyInformWritables([ + { Name: 'InternetGatewayDevice.ManagementServer.ParameterKey', Value: { _: '' } }, + ]) + + expect(d.getParameter('InternetGatewayDevice.ManagementServer.ParameterKey')).not.toBeNull() + expect(d.getProperty('InternetGatewayDevice.ManagementServer.ParameterKey')).toBeNull() + }) + + it('does not touch params that are already parameters', () => { + const d = makeDevice() + d.setParameter(new DeviceParameter('Device.DeviceInfo.ProvisioningCode', 'existing')) + + d.reclassifyInformWritables([ + { Name: 'Device.DeviceInfo.ProvisioningCode', Value: { _: 'existing' } }, + ]) + + expect(d.getParameter('Device.DeviceInfo.ProvisioningCode')!.value).toBe('existing') + expect(d.getProperty('Device.DeviceInfo.ProvisioningCode')).toBeNull() + }) + + it('does not reclassify unknown names (e.g. SoftwareVersion stays property)', () => { + const d = makeDevice() + d.setProperty(new DeviceProperty('InternetGatewayDevice.DeviceInfo.SoftwareVersion', 'v1')) + + d.reclassifyInformWritables([ + { Name: 'InternetGatewayDevice.DeviceInfo.SoftwareVersion', Value: { _: 'v1' } }, + ]) + + // SoftwareVersion is NOT in the writable list — should stay as property + expect(d.getProperty('InternetGatewayDevice.DeviceInfo.SoftwareVersion')).not.toBeNull() + expect(d.getParameter('InternetGatewayDevice.DeviceInfo.SoftwareVersion')).toBeNull() + }) + + it('handles Name as object node (XMLParser artefact)', () => { + const d = makeDevice() + d.setProperty(new DeviceProperty('Device.DeviceInfo.ProvisioningCode', 'code')) + + d.reclassifyInformWritables([ + { Name: { _: 'Device.DeviceInfo.ProvisioningCode' }, Value: { _: 'code' } }, + ]) + + expect(d.getParameter('Device.DeviceInfo.ProvisioningCode')).not.toBeNull() + expect(d.getProperty('Device.DeviceInfo.ProvisioningCode')).toBeNull() + }) +}) diff --git a/tests/unit/services/cwmp/config_eval_extra.spec.ts b/tests/unit/services/cwmp/config_eval_extra.spec.ts index a9357bb..23dad86 100644 --- a/tests/unit/services/cwmp/config_eval_extra.spec.ts +++ b/tests/unit/services/cwmp/config_eval_extra.spec.ts @@ -502,6 +502,69 @@ describe('FirmwareUpgradeState — download callback', () => { }) +describe('ConfigurationEvaluationState — inventory phase 2 GPV saves values on device', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('GPV callback in phase 2 calls updateFromValues so parameters are persisted', async () => { + const config = makeConfig({ + desiredParameters: [{ name: 'Device.X', value: '1', type: 'string' }], + inventory: [{ path: 'Device.ManagementServer.' }], + }) + vi.mocked(ConfigurationModel.find).mockResolvedValue([config]) + + const device = makeDevice() + device.setGpnStatus('supported') + const context = makeContext(device) + + const state = new ConfigurationEvaluationState() + + // Step 1: first handle → sends GPN for inventory + await state.handle(context as any) + + // Step 2: execute GPN callback with two leaf names + 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.PeriodicInformInterval', Writable: '1' }, + { Name: 'Device.ManagementServer.ConnectionRequestURL', Writable: '0' }, + ], + }, + }) + + // Step 3: second handle → sends GPV for discovered leaves (phase 2) + const result2 = await state.handle(context as any) + expect(result2.response).toContain('GetParameterValues') + + // Before GPV callback — device has no value for PeriodicInformInterval + const before = device.getParameters().find((p) => p.name === 'Device.ManagementServer.PeriodicInformInterval') + expect(before?.value ?? null).toBeNull() + + // Step 4: execute GPV callback with actual values + const gpvTaskId = Array.from(taskCallbacks.keys()).pop() + await context.taskQueue.executeTaskCallback(gpvTaskId!, ResultStatus.Success, { + ParameterList: { + ParameterValueStruct: [ + { Name: 'Device.ManagementServer.PeriodicInformInterval', Value: { _: '3600', $: { 'xsi:type': 'xsd:unsignedInt' } } }, + { Name: 'Device.ManagementServer.ConnectionRequestURL', Value: { _: 'http://cpe/cr', $: { 'xsi:type': 'xsd:string' } } }, + ], + }, + }) + + // After GPV callback — device must have the values saved + const after = device.getParameters().find((p) => p.name === 'Device.ManagementServer.PeriodicInformInterval') + expect(after).toBeDefined() + expect(after!.value).toBe('3600') + + const afterURL = device.getParameters().find((p) => p.name === 'Device.ManagementServer.ConnectionRequestURL') + expect(afterURL).toBeDefined() + expect(afterURL!.value).toBe('http://cpe/cr') + }) +}) + describe('ConfigurationEvaluationState — firmware upgrade transition (lines 154-158)', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/tests/unit/services/cwmp/device_action_executor.spec.ts b/tests/unit/services/cwmp/device_action_executor.spec.ts index d9bf9f8..141deef 100644 --- a/tests/unit/services/cwmp/device_action_executor.spec.ts +++ b/tests/unit/services/cwmp/device_action_executor.spec.ts @@ -1,8 +1,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { DeviceActionExecutor } from '../../../../app/services/cwmp/device_action_executor.js' -import { CWMPTaskQueue, ResultStatus, TaskResult } from '../../../../app/services/cwmp/task_queue.js' +import { CWMPTaskQueue, ResultStatus } from '../../../../app/services/cwmp/task_queue.js' import { Device } from '../../../../app/domain/device/device.js' import { DeviceId } from '../../../../app/domain/device/device_id.js' +import { DeviceProperty } from '../../../../app/domain/device/device_parameter.js' +import { ParameterType } from '../../../../app/domain/shared/types.js' // Mock AdonisJS logger vi.mock('@adonisjs/core/services/logger', () => ({ @@ -139,6 +141,44 @@ describe('DeviceActionExecutor', () => { expect(xml).toContain('SetParameterValues') expect(xml).toContain('ProvisioningCode') }) + + it('stores ProvisioningCode as a writable parameter (not property) after SPV success', async () => { + const device = makeDevice() + device.setDataModel('tr-181') + device.tmpPC = 'code-xyz' + + // Device has no prior ProvisioningCode entry + await executor.insertProvisioningCode(device, async () => '') + const task = queue.getTaskForDevice(device.deviceId) + await queue.executeTaskCallback(task!.id, ResultStatus.Success, {}) + + // Must be in parameters (writable), not properties (read-only) + const asParam = device.getParameter('Device.DeviceInfo.ProvisioningCode') + const asProp = device.getProperty('Device.DeviceInfo.ProvisioningCode') + expect(asParam).not.toBeNull() + expect(asProp).toBeNull() + expect(asParam!.value).toBe('code-xyz') + }) + + it('keeps ProvisioningCode as parameter when it already exists as property (reclassifies)', async () => { + const device = makeDevice() + device.setDataModel('tr-181') + device.tmpPC = 'new-code' + + // Simulate device where ProvisioningCode was incorrectly stored as property + device.setProperty(new DeviceProperty('Device.DeviceInfo.ProvisioningCode', 'old-code', ParameterType.STRING)) + + await executor.insertProvisioningCode(device, async () => '') + const task = queue.getTaskForDevice(device.deviceId) + await queue.executeTaskCallback(task!.id, ResultStatus.Success, {}) + + // After SPV success, must be a parameter, not a property + const asParam = device.getParameter('Device.DeviceInfo.ProvisioningCode') + const asProp = device.getProperty('Device.DeviceInfo.ProvisioningCode') + expect(asParam).not.toBeNull() + expect(asProp).toBeNull() + expect(asParam!.value).toBe('new-code') + }) }) describe('callbacks', () => { diff --git a/tests/unit/services/cwmp/gpn_circuit_breaker.spec.ts b/tests/unit/services/cwmp/gpn_circuit_breaker.spec.ts index 1de36b4..3ef3897 100644 --- a/tests/unit/services/cwmp/gpn_circuit_breaker.spec.ts +++ b/tests/unit/services/cwmp/gpn_circuit_breaker.spec.ts @@ -105,6 +105,77 @@ describe('GPN Circuit Breaker — DiscoveringParametersState', () => { expect(response).toContain('GetParameterNames') }) + it('DiscoveringParametersState processes GPN response and stores parameters', async () => { + const device = makeDevice() // tr-181 + device.setGpnStatus('unknown') + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-1', taskQueue) + + // Start: IdleState → DiscoveringParametersState → sends GPN for Device.DeviceInfo. + await sm.start() + + // Simulate a successful GPN response for DeviceInfo + 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 }, + { Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: true }, + { Name: 'Device.ManagementServer.ConnectionRequestURL', Writable: false }, + ], + }, + } + await sm.handleResponse(true, gpnResponse2) + + // gpnStatus should be '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() + }) + + it('DiscoveringParametersState sets gpnStatus supported even with empty param list', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-1', taskQueue) + + await sm.start() + + // Respond with empty list + await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } }) + + expect(device.gpnStatus).toBe('supported') + }) + it('DiscoveringParametersState calls recordGpnFailure and persists on fault', async () => { const device = makeDevice() device.setGpnStatus('unknown') 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 0251df4..4bff9d6 100644 --- a/tests/unit/services/cwmp/main_state_machine_extended.spec.ts +++ b/tests/unit/services/cwmp/main_state_machine_extended.spec.ts @@ -44,22 +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.ManagementServer. + // idle → discovering-parameters → sends GPN for Device.DeviceInfo. (first mandatory subtree) expect(response).toContain('GetParameterNames') + expect(response).toContain('Device.DeviceInfo.') 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 + // Simulate GPN success response for DeviceInfo (first path) await sm.handleResponse(true, { ParameterList: { - ParameterInfoStruct: [{ Name: 'Device.ManagementServer.URL', Writable: '1' }], + ParameterInfoStruct: [{ Name: 'Device.DeviceInfo.HardwareVersion', Writable: '0' }], }, }) - // After GPN success, should transition to critical-reads - expect(sm.getCurrentStateName()).toBe('critical-reads') + // Queue still has ManagementServer. → still in discovering-parameters + expect(sm.getCurrentStateName()).toBe('discovering-parameters') }) it('with unknown data model, skips to critical-reads', async () => { @@ -72,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.ManagementServer.') + expect(response).toContain('InternetGatewayDevice.DeviceInfo.') }) it('GPN failure sets gpnStatus unsupported and continues', async () => { @@ -90,7 +91,11 @@ describe('MainStateMachine', () => { await sm.start() expect(sm.getCurrentStateName()).toBe('discovering-parameters') - // Step 2: GPN response → critical-reads + // 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 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 1e4e08e..358b07b 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.ManagementServer.') + expect(response).toContain('InternetGatewayDevice.DeviceInfo.') }) it('skips GPN for unknown dataModel and goes to critical-reads', async () => { @@ -83,17 +83,19 @@ describe('MainStateMachine — additional coverage (DiscoveringParameters, GPN f }) it('DiscoveringParametersState handles GPN success and transitions to CriticalReads', async () => { - const device = makeDevice('unknown') + const device = makeDevice('unknown') // tr-181 → DeviceInfo. + ManagementServer. in queue const taskQueue = new CWMPTaskQueue() const sm = new MainStateMachine(device, 'sess', taskQueue) - // Start: sends GPN + // Start: sends GPN for Device.DeviceInfo. await sm.start() - // Simulate successful GPN response (empty parameter list) - const response = await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } }) + // First GPN response (DeviceInfo) → still in discovering-parameters (ManagementServer. pending) + await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } }) + expect(sm.getCurrentStateName()).toBe('discovering-parameters') - // Should transition to critical-reads and send GPV + // Second GPN response (ManagementServer) → 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') }) diff --git a/tests/unit/services/cwmp/task_queue.spec.ts b/tests/unit/services/cwmp/task_queue.spec.ts index d39f828..ae8eb8e 100644 --- a/tests/unit/services/cwmp/task_queue.spec.ts +++ b/tests/unit/services/cwmp/task_queue.spec.ts @@ -204,13 +204,16 @@ describe('CWMPTaskQueue — branch coverage', () => { const tq = new CWMPTaskQueue() const deviceId = new DeviceId('X', 'Y', 'Z', 'S') - tq.addCWMPTask(deviceId, 'get_parameter_values', ['a']) // PENDING + const pendingTask = tq.addCWMPTask(deviceId, 'get_parameter_values', ['a']) // PENDING const { taskId: sentId } = tq.addTask(deviceId, 'set_parameter_values', {}, async () => '') // SENT tq.clearPendingTasksForDevice(deviceId) + // clearPendingTasksForDevice clears ALL tasks (PENDING and SENT) to avoid + // ghost entries bleeding into the next session const tasks = (tq as any).tasks as Map - expect(tasks.has(sentId)).toBe(true) + expect(tasks.has(pendingTask.id)).toBe(false) + expect(tasks.has(sentId)).toBe(false) }) it('registerCallback adds callback for existing task', () => {