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>
This commit is contained in:
1 parent
dabe15839d
commit
aad93911e4
35 files changed
+8593
-4497
No files matched your search
@@ -0,0 +1,141 @@
|
||||
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([]),
|
||||
findByIdentifier: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock firmware catalog service
|
||||
vi.mock('../../../../app/services/firmware_catalog_service.js', () => ({
|
||||
default: class {
|
||||
async resolveUpgradePath() { return [] }
|
||||
},
|
||||
FirmwareCatalogService: class {
|
||||
async resolveUpgradePath() { return [] }
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock parameter_writability
|
||||
vi.mock('../../../../app/services/parameter_writability.js', () => ({
|
||||
reclassifyDeviceFromCatalog: vi.fn(),
|
||||
}))
|
||||
|
||||
import { ConfigurationEvaluationState, ComplianceUpdateState } from '../../../../app/services/cwmp/configuration_evaluation_state.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 { DeviceActionExecutor } from '../../../../app/services/cwmp/device_action_executor.js'
|
||||
import { ParameterType } from '../../../../app/domain/shared/types.js'
|
||||
import { buildProvisioningCode } from '../../../../app/utils/provisioning_code.js'
|
||||
|
||||
function makeDevice(): Device {
|
||||
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
|
||||
device.setDataModel('tr-181')
|
||||
device.setGpnStatus('unsupported')
|
||||
return device
|
||||
}
|
||||
|
||||
function makeContext(device: Device) {
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
return {
|
||||
device,
|
||||
session: 'test-session',
|
||||
taskQueue,
|
||||
services: {
|
||||
deviceAction: new DeviceActionExecutor(taskQueue),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConfigurationEvaluationState — skip inventory when already compliant', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('skips inventory when isAlreadyCompliant is true (no matching config)', async () => {
|
||||
const device = makeDevice()
|
||||
const context = makeContext(device)
|
||||
|
||||
const state = new ConfigurationEvaluationState()
|
||||
const result = await state.handle(context as any)
|
||||
|
||||
// With no configs in DB, evaluate returns isAlreadyCompliant: true
|
||||
// State should transition forward without sending any GPN/GPV
|
||||
// (no inventory, no diffs)
|
||||
expect(result.response).toBeFalsy()
|
||||
expect(result.nextState || result.isComplete).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComplianceUpdateState — skip provCode SPV when matching', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('skips SPV when device already has matching provisioning code', async () => {
|
||||
const device = makeDevice()
|
||||
|
||||
const mockConfig = { identifier: 'TestConfig', version: '1.0', desiredParameters: [], firmwareTarget: null }
|
||||
const expectedProvCode = buildProvisioningCode(mockConfig as any)
|
||||
|
||||
// Set the device's ProvisioningCode to match what buildProvisioningCode produces
|
||||
device.setProperty(new DeviceProperty(
|
||||
'Device.DeviceInfo.ProvisioningCode',
|
||||
expectedProvCode,
|
||||
ParameterType.STRING
|
||||
))
|
||||
|
||||
const context = makeContext(device)
|
||||
|
||||
const mockEngineResult = {
|
||||
configuration: mockConfig,
|
||||
inventoryPaths: [],
|
||||
parameterDiffs: [],
|
||||
firmwareMigrationPath: [],
|
||||
isAlreadyCompliant: true,
|
||||
skipFirmware: false,
|
||||
}
|
||||
|
||||
const state = new ComplianceUpdateState(mockEngineResult as any)
|
||||
const result = await state.handle(context as any)
|
||||
|
||||
// Should NOT produce a response (no SPV sent)
|
||||
// Should transition to ExecutingCommandsState
|
||||
expect(result.response).toBeFalsy()
|
||||
expect(result.nextState).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
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 configuration evaluation (skip config eval for these tests)
|
||||
vi.mock('../../../../app/services/cwmp/configuration_evaluation_state.js', () => ({
|
||||
ConfigurationEvaluationState: class {
|
||||
name = 'configuration-evaluation'
|
||||
async handle() { return { isComplete: true, response: '' } }
|
||||
},
|
||||
}))
|
||||
|
||||
// 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 { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js'
|
||||
|
||||
function makeDevice(): Device {
|
||||
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
|
||||
device.setDataModel('tr-181')
|
||||
device.setGpnStatus('unsupported') // Skip GPN, go to CriticalReads
|
||||
return device
|
||||
}
|
||||
|
||||
describe('CriticalReadsState — GPV response processing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('processes GPV response and updates device values', async () => {
|
||||
const device = makeDevice()
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'session-1', taskQueue)
|
||||
|
||||
// Start: IdleState → CriticalReadsState → sends GPV
|
||||
const gpvRequest = await sm.start()
|
||||
expect(gpvRequest).toContain('GetParameterValues')
|
||||
|
||||
// Simulate GPVResponse with ParameterValueStruct
|
||||
await sm.handleResponse(true, {
|
||||
ParameterList: {
|
||||
ParameterValueStruct: [
|
||||
{ Name: 'Device.DeviceInfo.HardwareVersion', Value: { _: 'v3.0', '$xsi:type': 'xsd:string' } },
|
||||
{ Name: 'Device.DeviceInfo.SoftwareVersion', Value: { _: 'SR50-5.0.0', '$xsi:type': 'xsd:string' } },
|
||||
{ Name: 'Device.DeviceInfo.ModelName', Value: { _: 'Livebox 4', '$xsi:type': 'xsd:string' } },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Device should have the values stored
|
||||
const hwProp = device.getProperty('Device.DeviceInfo.HardwareVersion')
|
||||
expect(hwProp).not.toBeNull()
|
||||
expect(hwProp!.value).toBe('v3.0')
|
||||
|
||||
const swProp = device.getProperty('Device.DeviceInfo.SoftwareVersion')
|
||||
expect(swProp).not.toBeNull()
|
||||
expect(swProp!.value).toBe('SR50-5.0.0')
|
||||
})
|
||||
|
||||
it('normalizes Name fields from object to string when xsi:type attribute is present', async () => {
|
||||
const device = makeDevice()
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'session-1', taskQueue)
|
||||
|
||||
await sm.start()
|
||||
|
||||
// Simulate GPVResponse where Name has xsi:type attribute (parsed as object by XMLParser)
|
||||
await sm.handleResponse(true, {
|
||||
ParameterList: {
|
||||
ParameterValueStruct: [
|
||||
{
|
||||
Name: { _: 'Device.DeviceInfo.HardwareVersion', '$xsi:type': 'xsd:string' },
|
||||
Value: { _: 'v2.1', '$xsi:type': 'xsd:string' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Should normalize Name._ to string and store correctly
|
||||
const hwProp = device.getProperty('Device.DeviceInfo.HardwareVersion')
|
||||
expect(hwProp).not.toBeNull()
|
||||
expect(hwProp!.value).toBe('v2.1')
|
||||
})
|
||||
|
||||
it('handles empty ParameterValueStruct gracefully', async () => {
|
||||
const device = makeDevice()
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'session-1', taskQueue)
|
||||
|
||||
await sm.start()
|
||||
|
||||
// Simulate empty GPV response
|
||||
await sm.handleResponse(true, { ParameterList: { ParameterValueStruct: [] } })
|
||||
|
||||
// Should not throw, machine should continue
|
||||
expect(sm.getCurrentStateName()).not.toBe('critical-reads')
|
||||
})
|
||||
|
||||
it('handles missing ParameterList gracefully', async () => {
|
||||
const device = makeDevice()
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'session-1', taskQueue)
|
||||
|
||||
await sm.start()
|
||||
|
||||
// Simulate response with no ParameterList
|
||||
await sm.handleResponse(true, {})
|
||||
|
||||
// Should not throw, machine should continue
|
||||
expect(sm.getCurrentStateName()).not.toBe('critical-reads')
|
||||
})
|
||||
|
||||
it('transitions to configuration-evaluation after processing', async () => {
|
||||
const device = makeDevice()
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'session-1', taskQueue)
|
||||
|
||||
await sm.start()
|
||||
await sm.handleResponse(true, {
|
||||
ParameterList: {
|
||||
ParameterValueStruct: [
|
||||
{ Name: 'Device.DeviceInfo.HardwareVersion', Value: { _: 'v1', '$xsi:type': 'xsd:string' } },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Should have transitioned past critical-reads to configuration-evaluation
|
||||
expect(sm.getCurrentStateName()).not.toBe('critical-reads')
|
||||
expect(sm.getCurrentStateName()).toBe('configuration-evaluation')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
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 configuration engine
|
||||
vi.mock('../../../../app/services/cwmp/configuration_evaluation_state.js', () => ({
|
||||
ConfigurationEvaluationState: class {
|
||||
name = 'configuration-evaluation'
|
||||
async handle() { return { isComplete: true, response: '' } }
|
||||
},
|
||||
}))
|
||||
|
||||
// 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 { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js'
|
||||
import DeviceModel from '../../../../app/models/device.js'
|
||||
|
||||
function makeDevice(): Device {
|
||||
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
|
||||
device.setDataModel('tr-181')
|
||||
return device
|
||||
}
|
||||
|
||||
describe('GPN Circuit Breaker — DiscoveringParametersState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('IdleState skips GPN when gpnStatus is unsupported', async () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('unsupported')
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'session-1', taskQueue)
|
||||
|
||||
// Start triggers IdleState → should skip GPN
|
||||
const response = await sm.start()
|
||||
|
||||
// Should produce a GPV (critical reads) not a GPN
|
||||
expect(response).toContain('GetParameterValues')
|
||||
expect(response).not.toContain('GetParameterNames')
|
||||
})
|
||||
|
||||
it('IdleState skips GPN when circuit breaker is open', async () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('unknown')
|
||||
device.setGpnCircuitBreaker({
|
||||
failureCount: 2,
|
||||
lastFailureDate: new Date(),
|
||||
state: 'open',
|
||||
cooldownUntil: new Date(Date.now() + 86400000),
|
||||
})
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'session-1', taskQueue)
|
||||
|
||||
const response = await sm.start()
|
||||
|
||||
// Should produce a GPV (critical reads) not a GPN
|
||||
expect(response).toContain('GetParameterValues')
|
||||
expect(response).not.toContain('GetParameterNames')
|
||||
})
|
||||
|
||||
it('IdleState proceeds to GPN when gpnStatus is unknown and circuit breaker is closed', async () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('unknown')
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'session-1', taskQueue)
|
||||
|
||||
const response = await sm.start()
|
||||
|
||||
// Should produce a GPN request
|
||||
expect(response).toContain('GetParameterNames')
|
||||
})
|
||||
|
||||
it('DiscoveringParametersState calls recordGpnFailure and persists on fault', async () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('unknown')
|
||||
const taskQueue = new CWMPTaskQueue()
|
||||
const sm = new MainStateMachine(device, 'session-1', taskQueue)
|
||||
|
||||
// Start: IdleState → DiscoveringParametersState → sends GPN
|
||||
await sm.start()
|
||||
|
||||
// Simulate CPE fault response
|
||||
const response = await sm.handleResponse(false, { FaultCode: '9001', FaultString: 'Method not supported' })
|
||||
|
||||
// Device should now be marked as unsupported
|
||||
expect(device.gpnStatus).toBe('unsupported')
|
||||
|
||||
// Circuit breaker should have recorded the failure
|
||||
expect(device.gpnCircuitBreaker.failureCount).toBe(1)
|
||||
|
||||
// Should have persisted via createOrUpdate
|
||||
expect(DeviceModel.createOrUpdate).toHaveBeenCalled()
|
||||
|
||||
// Should continue to critical reads (GPV)
|
||||
expect(response).toContain('GetParameterValues')
|
||||
})
|
||||
})
|
||||
@@ -13,8 +13,8 @@ vi.mock('../../../../app/models/command.js', () => ({
|
||||
default: {
|
||||
getCollection: () => ({
|
||||
find: vi.fn().mockReturnValue({
|
||||
sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }),
|
||||
}),
|
||||
}),
|
||||
updateCommand: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
@@ -54,17 +54,19 @@ describe('MainStateMachine', () => {
|
||||
await sm.start()
|
||||
// Simulate GPN success response
|
||||
const response = await sm.handleResponse(true, {
|
||||
ParameterList: { ParameterInfoStruct: [{ Name: 'Device.ManagementServer.URL', Writable: '1' }] },
|
||||
ParameterList: {
|
||||
ParameterInfoStruct: [{ Name: 'Device.ManagementServer.URL', Writable: '1' }],
|
||||
},
|
||||
})
|
||||
// After GPN success, should transition to configuration-evaluation
|
||||
expect(sm.getCurrentStateName()).toBe('configuration-evaluation')
|
||||
// After GPN success, should transition to critical-reads
|
||||
expect(sm.getCurrentStateName()).toBe('critical-reads')
|
||||
})
|
||||
|
||||
it('with unknown data model, skips to configuration-evaluation', async () => {
|
||||
it('with unknown data model, skips to critical-reads', async () => {
|
||||
const sm = new MainStateMachine(makeDevice('unknown'), 'sess-1', new CWMPTaskQueue())
|
||||
await sm.start()
|
||||
// idle → directly to configuration-evaluation (no GPN for unknown model)
|
||||
expect(sm.getCurrentStateName()).toBe('configuration-evaluation')
|
||||
// idle → directly to critical-reads (no GPN for unknown model)
|
||||
expect(sm.getCurrentStateName()).toBe('critical-reads')
|
||||
})
|
||||
|
||||
it('with tr-098, sends GPN for InternetGatewayDevice.ManagementServer.', async () => {
|
||||
@@ -82,23 +84,24 @@ describe('MainStateMachine', () => {
|
||||
expect(sm.getCurrentStateName()).not.toBe('discovering-parameters')
|
||||
})
|
||||
|
||||
it('full lifecycle: idle → discovering → config-eval → compliance → executing → completed', async () => {
|
||||
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
|
||||
await sm.start()
|
||||
expect(sm.getCurrentStateName()).toBe('discovering-parameters')
|
||||
|
||||
// Step 2: GPN response → config-eval
|
||||
// Step 2: GPN response → critical-reads
|
||||
await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } })
|
||||
expect(sm.getCurrentStateName()).toBe('configuration-evaluation')
|
||||
expect(sm.getCurrentStateName()).toBe('critical-reads')
|
||||
|
||||
// Step 3+: config eval will process its queue and eventually reach compliance-update
|
||||
// Since our mock engine returns inventoryPaths, it will send GPN requests
|
||||
// After all config eval actions complete, it reaches compliance-update → executing-commands → completed
|
||||
// Simulate multiple responses to drain the config eval state
|
||||
// Step 3+: critical reads will send GPV, after response moves to config eval
|
||||
// Simulate multiple responses to drain the state machine
|
||||
let guard = 0
|
||||
while (!sm.isCompleted() && guard++ < 20) {
|
||||
await sm.handleResponse(true, { Status: 0, ParameterList: { ParameterInfoStruct: [], ParameterValueStruct: [] } })
|
||||
await sm.handleResponse(true, {
|
||||
Status: 0,
|
||||
ParameterList: { ParameterInfoStruct: [], ParameterValueStruct: [] },
|
||||
})
|
||||
}
|
||||
expect(sm.isCompleted()).toBe(true)
|
||||
})
|
||||
@@ -127,7 +130,9 @@ describe('ExecutingCommandsState', () => {
|
||||
services: { deviceAction: { getParameterValues: vi.fn() } as any },
|
||||
}
|
||||
const result = await state.handle(context)
|
||||
expect(result.isComplete).toBe(true)
|
||||
// With includeNI=true and no commands, transitions to OperationalState
|
||||
expect(result.nextState).toBeDefined()
|
||||
expect(result.nextState!.name).toBe('operational')
|
||||
})
|
||||
|
||||
it('returns isComplete when no commands found (includeNI=false)', async () => {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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({}),
|
||||
},
|
||||
}))
|
||||
|
||||
import { StateMachineManager, MachineStatus } from '../../../../app/services/cwmp/state_machine_manager.js'
|
||||
import { Device } from '../../../../app/domain/device/device.js'
|
||||
import { DeviceId } from '../../../../app/domain/device/device_id.js'
|
||||
|
||||
function makeDevice(): Device {
|
||||
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
|
||||
device.setDataModel('tr-181')
|
||||
device.setGpnStatus('unsupported')
|
||||
return device
|
||||
}
|
||||
|
||||
describe('StateMachineManager — stale session replacement', () => {
|
||||
let manager: StateMachineManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset singleton
|
||||
;(StateMachineManager as any).instance = null
|
||||
manager = StateMachineManager.getInstance()
|
||||
})
|
||||
|
||||
it('replaces stale MainStateMachine when session differs', async () => {
|
||||
const device = makeDevice()
|
||||
|
||||
// Start first machine on session-A
|
||||
const machine1 = await manager.startStateMachine(device, 'session-A', { events: ['0 BOOTSTRAP'] })
|
||||
expect(machine1.status).toBe(MachineStatus.RUNNING)
|
||||
expect(machine1.session).toBe('session-A')
|
||||
|
||||
// Start second machine on session-B (different session, same device)
|
||||
const machine2 = await manager.startStateMachine(device, 'session-B', { events: ['2 PERIODIC'] })
|
||||
|
||||
// Should be a NEW machine (not the old one rebinding)
|
||||
expect(machine2.session).toBe('session-B')
|
||||
// Old machine should be completed
|
||||
expect(machine1.status).toBe(MachineStatus.COMPLETED)
|
||||
})
|
||||
|
||||
it('does NOT replace machine when session is same', async () => {
|
||||
const device = makeDevice()
|
||||
|
||||
// Start machine on session-A
|
||||
const machine1 = await manager.startStateMachine(device, 'session-A', { events: ['0 BOOTSTRAP'] })
|
||||
|
||||
// Start again on SAME session
|
||||
const machine2 = await manager.startStateMachine(device, 'session-A', { events: ['2 PERIODIC'] })
|
||||
|
||||
// Should return the same machine (rebinding)
|
||||
expect(machine2.id).toBe(machine1.id)
|
||||
expect(machine1.status).toBe(MachineStatus.RUNNING)
|
||||
})
|
||||
|
||||
it('creates fresh machine after replacing stale one', async () => {
|
||||
const device = makeDevice()
|
||||
|
||||
// Start first machine
|
||||
await manager.startStateMachine(device, 'session-old', { events: ['0 BOOTSTRAP'] })
|
||||
|
||||
// Start new machine with different session
|
||||
const newMachine = await manager.startStateMachine(device, 'session-new', { events: ['2 PERIODIC'] })
|
||||
|
||||
// Fresh machine should be running
|
||||
expect(newMachine.status).toBe(MachineStatus.RUNNING)
|
||||
expect(newMachine.session).toBe('session-new')
|
||||
// Should be a MainStateMachine (PERIODIC event triggers MainStateMachine)
|
||||
expect(newMachine.currentState).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -36,21 +36,22 @@ describe('CWMPTaskQueue', () => {
|
||||
|
||||
describe('addTask', () => {
|
||||
it('adds task, marks as SENT, and returns SOAP XML', () => {
|
||||
const xml = queue.addTask(makeDeviceId(), 'reboot', { commandKey: 'test' }, async () => '')
|
||||
expect(xml).toContain('Reboot')
|
||||
expect(xml).toContain('test')
|
||||
const result = queue.addTask(makeDeviceId(), 'reboot', { commandKey: 'test' }, async () => '')
|
||||
expect(result.xml).toContain('Reboot')
|
||||
expect(result.xml).toContain('test')
|
||||
expect(result.taskId).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns empty string for unknown task type', () => {
|
||||
const xml = queue.addTask(makeDeviceId(), 'unknown_type', {}, async () => '')
|
||||
expect(xml).toBe('')
|
||||
const result = queue.addTask(makeDeviceId(), 'unknown_type', {}, async () => '')
|
||||
expect(result.xml).toBe('')
|
||||
})
|
||||
|
||||
it('emits taskSent event', () => {
|
||||
it('does not emit taskSent event (race condition fix)', () => {
|
||||
let sent = false
|
||||
queue.on('taskSent', () => { sent = true })
|
||||
queue.addTask(makeDeviceId(), 'factory_reset', {}, async () => '')
|
||||
expect(sent).toBe(true)
|
||||
expect(sent).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('MainStateMachine — TR-181 device', () => {
|
||||
// Simulate any response (success or failure) — state machine advances
|
||||
await sm.handleResponse(true, {})
|
||||
|
||||
// Should have moved past discovering-parameters (no more paths in queue)
|
||||
// Should have moved past discovering-parameters (now in critical-reads)
|
||||
expect(sm.getCurrentStateName()).not.toBe('discovering-parameters')
|
||||
expect(sm.getCurrentStateName()).not.toBe('idle')
|
||||
})
|
||||
@@ -198,16 +198,17 @@ describe('MainStateMachine — TR-098 device', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('MainStateMachine — unknown data model', () => {
|
||||
it('skips GPN and goes directly to configuration-evaluation', async () => {
|
||||
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 entirely
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -254,7 +255,7 @@ describe('MainStateMachine — CompletedState reclassification', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DeviceActionExecutor — reclassification after updateFromValues', () => {
|
||||
it('reclassifies properties added via SPV callback when GPN unsupported', async () => {
|
||||
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)
|
||||
@@ -282,10 +283,9 @@ describe('DeviceActionExecutor — reclassification after updateFromValues', ()
|
||||
|
||||
await taskQueue.executeTaskCallback(spvTask!.id, 'success' as any, { Status: 0 })
|
||||
|
||||
// ProvisioningCode should now be a parameter (reclassified after updateFromValues)
|
||||
expect(device.getParameter('Device.DeviceInfo.ProvisioningCode')).not.toBeNull()
|
||||
expect(device.getParameter('Device.DeviceInfo.ProvisioningCode')!.value).toBe('test-code')
|
||||
expect(device.getProperty('Device.DeviceInfo.ProvisioningCode')).toBeNull()
|
||||
// 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 () => {
|
||||
|
||||
Reference in new issue
Block a user