Files
adam-acs/tests/unit/services/main_state_machine.spec.ts
T
Kevin L'HereecandContinue aad93911e4 feat: TR-069 CWMP state machine hardening + exchange viewer improvements
- TR-069 CWMP state machine hardening (14 phases): shared event utility,
  multi-event routing, GPN circuit breaker, IdleState GPN guard, expanded
  GPN fault codes, CriticalReadsState, ConfigEval GPN guard, task queue
  race condition fix, explicit error handling, terminal states
  (OperationalState/PartialOperationalState), consolidated reclassify,
  crash pattern detection, removed enqueueImmediate, state flow wiring
- Fix SOAP Fault not processed by state machine (soap-env:Fault detection)
- Fix GPN circuit breaker not persisting after session drops
- Fix CriticalReadsState GPV response parsing (xsi:type normalization)
- Fix CriticalReadsState not processing GPV response values
- Skip SPV when device is already compliant (ProvisioningCode check)
- Add Device.DeviceInfo.ModelName to critical reads GPV
- Fix TypeError on DeviceDetail parameters view (null guards)
- Log empty 204 responses in exchange trace UI
- TR-181 catalog: type corrections and new entries (+6640 lines)
- SequenceViewer: replace session-nav buttons with compact dropdown select
- SequenceViewer: fix CPE/ACS lifeline headers wrapping to 2 lines
- Add 20 unit tests for state machine fixes (SOAP Fault, GPN circuit
  breaker, CriticalReads, stale session, ConfigEval/ComplianceUpdate skips)

Generated with [Continue](https://continue.dev)

Co-Authored-By: Continue <noreply@continue.dev>
2026-06-08 12:49:00 +00:00

318 lines
13 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock AdonisJS logger before imports
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}))
// Mock DeviceModel to avoid MongoDB dependency
vi.mock('../../../app/models/device.js', () => ({
default: {
createOrUpdate: vi.fn().mockResolvedValue({}),
findByDeviceId: vi.fn().mockResolvedValue(null),
},
}))
// 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),
}),
},
}))
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 { 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'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeTr181Device(): Device {
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
device.setDataModel('tr-181')
return device
}
function makeTr098Device(): Device {
const device = new Device(new DeviceId('Huawei Technologies Co., Ltd.', '202BC1', 'BM632w', '000000'))
device.setDataModel('tr-098')
return device
}
function makeUnknownDevice(): Device {
const device = new Device(new DeviceId('Unknown', '000000', 'Generic', 'SN001'))
device.setDataModel('unknown')
return device
}
// ---------------------------------------------------------------------------
// MainStateMachine — Initial state
// ---------------------------------------------------------------------------
describe('MainStateMachine — initial state', () => {
it('starts in idle state', () => {
const device = makeTr181Device()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
expect(sm.getCurrentStateName()).toBe('idle')
expect(sm.isCompleted()).toBe(false)
})
})
// ---------------------------------------------------------------------------
// MainStateMachine — TR-181 device flow (GPN on Device.ManagementServer.)
// ---------------------------------------------------------------------------
describe('MainStateMachine — TR-181 device', () => {
let device: Device
let taskQueue: CWMPTaskQueue
let sm: MainStateMachine
beforeEach(() => {
device = makeTr181Device()
taskQueue = new CWMPTaskQueue()
sm = new MainStateMachine(device, 'session-1', taskQueue)
})
it('transitions to discovering-parameters and sends GPN for Device.ManagementServer.', async () => {
const response = await sm.start()
expect(sm.getCurrentStateName()).toBe('discovering-parameters')
// Response should be a SOAP envelope with GetParameterNames
expect(response).toContain('GetParameterNames')
expect(response).toContain('Device.ManagementServer.')
})
it('advances past discovering-parameters after receiving a response', async () => {
await sm.start() // sends GPN, state = discovering-parameters
// Simulate any response (success or failure) — state machine advances
await sm.handleResponse(true, {})
// Should have moved past discovering-parameters (now in critical-reads)
expect(sm.getCurrentStateName()).not.toBe('discovering-parameters')
expect(sm.getCurrentStateName()).not.toBe('idle')
})
it('GPN callback sets gpnStatus to unsupported on fault code 9000', async () => {
await sm.start()
// Get the task from the queue and execute its callback manually
const tasks: any[] = Array.from((taskQueue as any).tasks.values())
const gpnTask = tasks.find((t: any) => t.type === 'get_parameter_names')
expect(gpnTask).toBeDefined()
// Execute the callback with error
await taskQueue.executeTaskCallback(gpnTask!.id, 'error' as any, {
FaultCode: 9000,
FaultString: 'Method not supported',
})
expect(device.gpnStatus).toBe('unsupported')
})
it('GPN callback sets gpnStatus to supported on success', async () => {
await sm.start()
const tasks: any[] = Array.from((taskQueue as any).tasks.values())
const gpnTask = tasks.find((t: any) => t.type === 'get_parameter_names')
expect(gpnTask).toBeDefined()
// Execute the callback with success
await taskQueue.executeTaskCallback(gpnTask!.id, 'success' as any, {
ParameterList: {
ParameterInfoStruct: [
{ Name: 'Device.ManagementServer.URL', Writable: true },
{ Name: 'Device.ManagementServer.ParameterKey', Writable: false },
],
},
})
expect(device.gpnStatus).toBe('supported')
})
it('GPN callback organizes parameters correctly on success', async () => {
device.setProperty(new DeviceProperty('Device.ManagementServer.URL', 'http://acs', ParameterType.STRING))
device.setProperty(new DeviceProperty('Device.ManagementServer.ParameterKey', '', ParameterType.STRING))
await sm.start()
const tasks: any[] = Array.from((taskQueue as any).tasks.values())
const gpnTask = tasks.find((t: any) => t.type === 'get_parameter_names')
await taskQueue.executeTaskCallback(gpnTask!.id, 'success' as any, {
ParameterList: {
ParameterInfoStruct: [
{ Name: 'Device.ManagementServer.URL', Writable: true },
{ Name: 'Device.ManagementServer.ParameterKey', Writable: false },
],
},
})
// URL was property + writable in GPN → should be parameter now
expect(device.getParameter('Device.ManagementServer.URL')).not.toBeNull()
// ParameterKey was property + NOT writable → stays property
expect(device.getProperty('Device.ManagementServer.ParameterKey')).not.toBeNull()
})
})
// ---------------------------------------------------------------------------
// MainStateMachine — TR-098 device flow (GPN on InternetGatewayDevice.ManagementServer.)
// ---------------------------------------------------------------------------
describe('MainStateMachine — TR-098 device', () => {
let device: Device
let taskQueue: CWMPTaskQueue
let sm: MainStateMachine
beforeEach(() => {
device = makeTr098Device()
taskQueue = new CWMPTaskQueue()
sm = new MainStateMachine(device, 'session-1', taskQueue)
})
it('sends GPN for InternetGatewayDevice.ManagementServer.', async () => {
const response = await sm.start()
expect(sm.getCurrentStateName()).toBe('discovering-parameters')
expect(response).toContain('GetParameterNames')
expect(response).toContain('InternetGatewayDevice.ManagementServer.')
})
})
// ---------------------------------------------------------------------------
// MainStateMachine — Unknown data model
// ---------------------------------------------------------------------------
describe('MainStateMachine — unknown data model', () => {
it('skips GPN and goes directly to critical-reads', async () => {
const device = makeUnknownDevice()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
await sm.start()
// Should skip discovering-parameters and go to critical-reads
expect(sm.getCurrentStateName()).not.toBe('discovering-parameters')
expect(sm.getCurrentStateName()).not.toBe('idle')
expect(sm.getCurrentStateName()).toBe('critical-reads')
})
})
// ---------------------------------------------------------------------------
// MainStateMachine — CompletedState reclassification
// ---------------------------------------------------------------------------
describe('MainStateMachine — CompletedState reclassification', () => {
it('reclassifies writable properties on completion when GPN unsupported', async () => {
const device = makeTr181Device()
device.setGpnStatus('unsupported')
// Add writable property that should be reclassified
device.setProperty(new DeviceProperty('Device.ManagementServer.PeriodicInformInterval', '60', ParameterType.UNSIGNED_INT))
// Add read-only property that should stay
device.setProperty(new DeviceProperty('Device.DeviceInfo.Manufacturer', 'Sagemcom', ParameterType.STRING))
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
// Start → since dataModel is tr-181, it will try GPN
await sm.start()
// Simulate GPN failure
await sm.handleResponse(true, {})
// The state machine should eventually complete
// Let it run through (no config match → completes)
let guard = 0
while (!sm.isCompleted() && guard++ < 10) {
await sm.handleResponse(true, {})
}
// After completion, PeriodicInformInterval should be a parameter
expect(device.getParameter('Device.ManagementServer.PeriodicInformInterval')).not.toBeNull()
expect(device.getProperty('Device.ManagementServer.PeriodicInformInterval')).toBeNull()
// Manufacturer should stay as property
expect(device.getProperty('Device.DeviceInfo.Manufacturer')).not.toBeNull()
expect(device.getParameter('Device.DeviceInfo.Manufacturer')).toBeNull()
})
})
// ---------------------------------------------------------------------------
// DeviceActionExecutor — reclassification after SPV (Livebox fallback scenario)
// ---------------------------------------------------------------------------
describe('DeviceActionExecutor — reclassification after updateFromValues', () => {
it('SPV callback updates device values but does not reclassify (consolidated to terminal state)', async () => {
const device = makeTr181Device()
device.setGpnStatus('unsupported')
// Device starts with only read-only properties (like after first Inform)
device.setProperty(new DeviceProperty('Device.DeviceInfo.Manufacturer', 'Sagemcom', ParameterType.STRING))
const taskQueue = new CWMPTaskQueue()
// Import DeviceActionExecutor
const { DeviceActionExecutor } = await import('../../../app/services/cwmp/device_action_executor.js')
const executor = new DeviceActionExecutor(taskQueue)
// Simulate SPV for ProvisioningCode (like ConfigurationEvaluationState does)
const spvXml = await executor.applySetParameterValues(
device,
[{ parameterName: 'Device.DeviceInfo.ProvisioningCode', value: 'test-code' }],
async () => ''
)
expect(spvXml).toContain('SetParameterValues')
// Get the task and execute callback with success (simulating CPE response)
const tasks: any[] = Array.from((taskQueue as any).tasks.values())
const spvTask = tasks.find((t: any) => t.type === 'set_parameter_values')
expect(spvTask).toBeDefined()
await taskQueue.executeTaskCallback(spvTask!.id, 'success' as any, { Status: 0 })
// ProvisioningCode is stored as a property (reclassification happens later in OperationalState)
expect(device.getProperty('Device.DeviceInfo.ProvisioningCode')).not.toBeNull()
expect(device.getProperty('Device.DeviceInfo.ProvisioningCode')!.value).toBe('test-code')
})
it('does NOT reclassify when GPN is supported (live data is authoritative)', async () => {
const device = makeTr181Device()
device.setGpnStatus('supported')
device.setProperty(new DeviceProperty('Device.DeviceInfo.Manufacturer', 'Sagemcom', ParameterType.STRING))
const taskQueue = new CWMPTaskQueue()
const { DeviceActionExecutor } = await import('../../../app/services/cwmp/device_action_executor.js')
const executor = new DeviceActionExecutor(taskQueue)
// SPV for a writable param — but GPN is supported so no reclassification
await executor.applySetParameterValues(
device,
[{ parameterName: 'Device.ManagementServer.PeriodicInformInterval', value: '120' }],
async () => ''
)
const tasks: any[] = Array.from((taskQueue as any).tasks.values())
const spvTask = tasks.find((t: any) => t.type === 'set_parameter_values')
await taskQueue.executeTaskCallback(spvTask!.id, 'success' as any, { Status: 0 })
// PeriodicInformInterval was added via updateFromValues as a property
// But since GPN is supported, reclassification is NOT called
// It stays as property (the GPN response is the authority)
expect(device.getProperty('Device.ManagementServer.PeriodicInformInterval')).not.toBeNull()
expect(device.getParameter('Device.ManagementServer.PeriodicInformInterval')).toBeNull()
})
})