Files
adam-acs/tests/unit/services/main_state_machine.spec.ts
T

313 lines
12 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 reclassifyDeviceFromCatalog
vi.mock('../../../app/services/parameter_writability.js', () => ({
reclassifyDeviceFromCatalog: 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'
import { reclassifyDeviceFromCatalog } from '../../../app/services/parameter_writability.js'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeTr181Device(): Device {
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
device.setDataModel('tr-181')
device.setTmpParamValues([{ Name: 'Device.ManagementServer.ConnectionRequestURL', Value: { _: 'http://cpe/' } }])
return device
}
function makeTr098Device(): Device {
const device = new Device(new DeviceId('Huawei Technologies Co., Ltd.', '202BC1', 'BM632w', '000000'))
device.setDataModel('tr-098')
device.setTmpParamValues([{ Name: 'InternetGatewayDevice.ManagementServer.ConnectionRequestURL', Value: { _: 'http://cpe/' } }])
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 — IdleState reclassification via catalog', () => {
it('reclassifies unclassified params via catalog when GPN unsupported', async () => {
const device = makeTr181Device()
device.setGpnStatus('unsupported')
// tmpParamValues has an unclassified param (not in _parameters or _properties)
device.setTmpParamValues([
{ Name: 'Device.ManagementServer.PeriodicInformInterval', Value: { _: '60' } },
])
// This param is NOT in device._parameters or device._properties → unclassified
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
// Start → IdleState detects unclassified param, GPN unsupported → calls catalog
await sm.start()
// reclassifyDeviceFromCatalog should have been called
expect(vi.mocked(reclassifyDeviceFromCatalog)).toHaveBeenCalled()
})
})
// ---------------------------------------------------------------------------
// 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()
})
})