Adds targeted unit tests to cover previously untested branches/functions in the business-critical (non-protocol) services: - device_service.ts: 35.8% -> 98.76% (CRUD, executeCommand, refreshDevice, calculatePagination, IPv4/IPv6 Connection Request fallback chain) - domain/device/device.ts: 76.7% -> 96.59% (GPN circuit breaker state machine, session lifecycle, canExecuteCommands, detectDataModelFromParams) - services/references/reference_engine.ts: 78.66% -> 100% (provider loading edge cases, rejection handling, singleton lifecycle) - services/configuration_engine.ts: 70.96% -> 98.7% (matchesCriteria branches, shouldApplyOnce policy variants, dryRun, complianceReport) - services/cwmp/state_machine_manager.ts: 70.9% -> 87.27% (getHashIdForRemoteIp, ensureSessionForRemote IP fallback, task callback error handling) - services/cwmp/main_state_machine.ts: 57.79% -> 94.29% (ExecutingCommandsState full command-type dispatch, MAX_STATE_TRANSITIONS guard, error-> PartialOperationalState transitions) Also fixes: - reference_engine_full.spec.ts previously depended on the real config/references.json (production config, wassup provider legitimately disabled there) instead of a test fixture — now mocks config_loader for deterministic, isolated assertions - main_state_machine.spec.ts CommandModel.getCollection mock returned a new object per call instead of a shared instance, breaking setupCommandMock() for the new command-dispatch tests Overall project coverage (excluding protocol/): 72.76% -> 82.34% statements. Full suite: 1005/1005 tests passing.
760 lines
28 KiB
TypeScript
760 lines
28 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
vi.mock('@adonisjs/core/services/logger', () => ({
|
|
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
}))
|
|
vi.mock('#models/device', () => ({
|
|
default: {
|
|
createOrUpdate: vi.fn().mockResolvedValue({}),
|
|
updateOne: vi.fn().mockResolvedValue({}),
|
|
},
|
|
}))
|
|
const sharedCommandCollection = {
|
|
find: vi.fn().mockReturnValue({
|
|
sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }),
|
|
}),
|
|
}
|
|
vi.mock('../../../../app/models/command.js', () => ({
|
|
default: {
|
|
getCollection: vi.fn(() => sharedCommandCollection),
|
|
updateCommand: vi.fn().mockResolvedValue({}),
|
|
},
|
|
}))
|
|
|
|
import {
|
|
MainStateMachine,
|
|
ExecutingCommandsState,
|
|
OperationalState,
|
|
PartialOperationalState,
|
|
} 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 { DeviceParameter, DeviceProperty } from '../../../../app/domain/device/device_parameter.js'
|
|
import { CWMPTaskQueue, ResultStatus } 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 { CommandStatus, CommandType } from '../../../../app/domain/commands/command.js'
|
|
import { ModeType } from '../../../../app/domain/shared/mode.js'
|
|
import DeviceModel from '../../../../app/models/device.js'
|
|
import CommandModel from '../../../../app/models/command.js'
|
|
import logger from '@adonisjs/core/services/logger'
|
|
|
|
function makeDevice(dataModel: string = 'tr-181') {
|
|
const d = new Device(new DeviceId('Mfr', 'OUI', 'PC', 'SN'))
|
|
d.setDataModel(dataModel as any)
|
|
// Simulate Inform params (tmpParamValues) so IdleState can detect unclassified params
|
|
const root = dataModel === 'tr-098' ? 'InternetGatewayDevice' : 'Device'
|
|
d.setTmpParamValues([
|
|
{ Name: `${root}.ManagementServer.ConnectionRequestURL`, Value: { _: 'http://cpe/' } },
|
|
])
|
|
return d
|
|
}
|
|
|
|
function makeContext(device: Device) {
|
|
const taskQueue = new CWMPTaskQueue()
|
|
return {
|
|
device,
|
|
session: 'test-session',
|
|
message: undefined as any,
|
|
taskQueue,
|
|
services: { deviceAction: new DeviceActionExecutor(taskQueue) },
|
|
}
|
|
}
|
|
|
|
function setupCommandMock(commands: any[]) {
|
|
const collection = CommandModel.getCollection()
|
|
let callCount = 0
|
|
vi.mocked(collection.find).mockImplementation(
|
|
() =>
|
|
({
|
|
sort: () => ({
|
|
toArray: () => {
|
|
const result = callCount === 0 ? commands : []
|
|
callCount++
|
|
return Promise.resolve(result)
|
|
},
|
|
}),
|
|
}) as any
|
|
)
|
|
}
|
|
|
|
describe('MainStateMachine', () => {
|
|
it('starts in idle state', () => {
|
|
const sm = new MainStateMachine(makeDevice(), 'sess-1', new CWMPTaskQueue())
|
|
expect(sm.getCurrentStateName()).toBe('idle')
|
|
expect(sm.isCompleted()).toBe(false)
|
|
})
|
|
|
|
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. (single test path)
|
|
expect(response).toContain('GetParameterNames')
|
|
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 → transitions to critical-reads (single path queue)
|
|
await sm.handleResponse(true, {
|
|
ParameterList: {
|
|
ParameterInfoStruct: [
|
|
{ Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: '1' },
|
|
],
|
|
},
|
|
})
|
|
// Only one path in queue → transitions to critical-reads
|
|
expect(sm.getCurrentStateName()).toBe('critical-reads')
|
|
})
|
|
|
|
it('with unknown data model and no unclassified params, skips to critical-reads', async () => {
|
|
const device = new Device(new DeviceId('Mfr', 'OUI', 'PC', 'SN'))
|
|
device.setDataModel('unknown' as any)
|
|
// No tmpParamValues → nothing to classify → skips to critical-reads
|
|
const sm = new MainStateMachine(device, 'sess-1', new CWMPTaskQueue())
|
|
await sm.start()
|
|
expect(sm.getCurrentStateName()).toBe('critical-reads')
|
|
})
|
|
|
|
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.')
|
|
})
|
|
|
|
it('GPN failure sets gpnStatus unsupported and continues', async () => {
|
|
const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue())
|
|
await sm.start()
|
|
// Simulate GPN fault (9001 = method not supported)
|
|
await sm.handleResponse(false, { FaultCode: 9001, FaultString: 'Method not supported' })
|
|
// Should have transitioned past discovering-parameters
|
|
expect(sm.getCurrentStateName()).not.toBe('discovering-parameters')
|
|
})
|
|
|
|
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 (single path test)
|
|
await sm.start()
|
|
expect(sm.getCurrentStateName()).toBe('discovering-parameters')
|
|
|
|
// Step 2: GPN response → transitions to critical-reads
|
|
await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } })
|
|
expect(sm.getCurrentStateName()).toBe('critical-reads')
|
|
|
|
// 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: [] },
|
|
})
|
|
}
|
|
expect(sm.isCompleted()).toBe(true)
|
|
})
|
|
|
|
it('error in state transitions to completed gracefully', async () => {
|
|
const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue())
|
|
await sm.start()
|
|
// Force an unexpected error by passing malformed data many times
|
|
for (let i = 0; i < 20; i++) {
|
|
await sm.handleResponse(false, { error: 'unexpected' })
|
|
if (sm.isCompleted()) break
|
|
}
|
|
// Should eventually complete (safety guard or error handling)
|
|
expect(sm.isCompleted()).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('ExecutingCommandsState', () => {
|
|
it('returns isComplete when no commands found (includeNI=true)', async () => {
|
|
const state = new ExecutingCommandsState(true)
|
|
const device = makeDevice()
|
|
const context = {
|
|
device,
|
|
session: 'sess-1',
|
|
taskQueue: new CWMPTaskQueue(),
|
|
services: { deviceAction: { getParameterValues: vi.fn() } as any },
|
|
}
|
|
const result = await state.handle(context)
|
|
// 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 () => {
|
|
const state = new ExecutingCommandsState(false)
|
|
const device = makeDevice()
|
|
const context = {
|
|
device,
|
|
session: 'sess-1',
|
|
taskQueue: new CWMPTaskQueue(),
|
|
services: { deviceAction: {} as any },
|
|
}
|
|
const result = await state.handle(context)
|
|
expect(result.isComplete).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('ExecutingCommandsState.processNextCommand — command type dispatch', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
function makeCommand(action: CommandType, parameters: any = {}) {
|
|
return {
|
|
_id: 'cmd-1',
|
|
deviceIdentifier: 'hash-1',
|
|
mode: ModeType.NI,
|
|
action,
|
|
parameters,
|
|
status: CommandStatus.PENDING,
|
|
priority: 1,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
}
|
|
}
|
|
|
|
it('dispatches GET_PARAMETER_VALUES with a names array', async () => {
|
|
setupCommandMock([makeCommand(CommandType.GET_PARAMETER_VALUES, ['Device.Info.SW'])])
|
|
const state = new ExecutingCommandsState(true)
|
|
const device = makeDevice()
|
|
const context = makeContext(device)
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('GetParameterValues')
|
|
expect(result.response).toContain('Device.Info.SW')
|
|
})
|
|
|
|
it('dispatches GET_PARAMETER_VALUES using the "paths" property fallback', async () => {
|
|
setupCommandMock([makeCommand(CommandType.GET_PARAMETER_VALUES, { paths: ['Device.Info.HW'] })])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('Device.Info.HW')
|
|
})
|
|
|
|
it('dispatches GET_PARAMETER_NAMES with a string path', async () => {
|
|
setupCommandMock([makeCommand(CommandType.GET_PARAMETER_NAMES, 'Device.WiFi.')])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('GetParameterNames')
|
|
expect(result.response).toContain('Device.WiFi.')
|
|
})
|
|
|
|
it('dispatches GET_PARAMETER_NAMES using the "path" property fallback', async () => {
|
|
setupCommandMock([makeCommand(CommandType.GET_PARAMETER_NAMES, { path: 'Device.LAN.' })])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('Device.LAN.')
|
|
})
|
|
|
|
it('dispatches GET_PARAMETER_ATTRIBUTES with a names array', async () => {
|
|
setupCommandMock([
|
|
makeCommand(CommandType.GET_PARAMETER_ATTRIBUTES, ['Device.Info.Manufacturer']),
|
|
])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('GetParameterAttributes')
|
|
})
|
|
|
|
it('dispatches SET_PARAMETER_VALUES with an array of {name, value}', async () => {
|
|
setupCommandMock([
|
|
makeCommand(CommandType.SET_PARAMETER_VALUES, [
|
|
{ name: 'Device.WiFi.SSID', value: 'MyWifi', type: 'xsd:string' },
|
|
]),
|
|
])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('SetParameterValues')
|
|
expect(result.response).toContain('MyWifi')
|
|
})
|
|
|
|
it('dispatches SET_PARAMETER_VALUES using the "parameterValues" property fallback', async () => {
|
|
setupCommandMock([
|
|
makeCommand(CommandType.SET_PARAMETER_VALUES, {
|
|
parameterValues: [{ parameterName: 'Device.WiFi.Enable', value: true }],
|
|
}),
|
|
])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('SetParameterValues')
|
|
})
|
|
|
|
it('dispatches SET_PARAMETER_ATTRIBUTES', async () => {
|
|
setupCommandMock([
|
|
makeCommand(CommandType.SET_PARAMETER_ATTRIBUTES, [
|
|
{ name: 'Device.WiFi.SSID', notification: 1, accessList: [] },
|
|
]),
|
|
])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('SetParameterAttributes')
|
|
})
|
|
|
|
it('dispatches DOWNLOAD', async () => {
|
|
setupCommandMock([
|
|
makeCommand(CommandType.DOWNLOAD, {
|
|
fileType: '1 Firmware Upgrade Image',
|
|
url: 'http://fw/image.bin',
|
|
fileSize: 1000,
|
|
}),
|
|
])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('Download')
|
|
})
|
|
|
|
it('dispatches UPLOAD', async () => {
|
|
setupCommandMock([makeCommand(CommandType.UPLOAD, { fileType: '1 Vendor Configuration File' })])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('Upload')
|
|
})
|
|
|
|
it('dispatches REBOOT', async () => {
|
|
setupCommandMock([makeCommand(CommandType.REBOOT, {})])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('Reboot')
|
|
})
|
|
|
|
it('dispatches FACTORY_RESET', async () => {
|
|
setupCommandMock([makeCommand(CommandType.FACTORY_RESET, {})])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('FactoryReset')
|
|
})
|
|
|
|
it('dispatches SCHEDULE_INFORM with a default delay when none provided', async () => {
|
|
setupCommandMock([makeCommand(CommandType.SCHEDULE_INFORM, {})])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('ScheduleInform')
|
|
})
|
|
|
|
it('dispatches REFRESH by re-reading all known non-empty leaf parameters', async () => {
|
|
setupCommandMock([makeCommand(CommandType.REFRESH, {})])
|
|
const state = new ExecutingCommandsState(true)
|
|
const device = makeDevice()
|
|
device.setParameter(
|
|
new DeviceParameter(
|
|
'Device.ManagementServer.PeriodicInformInterval',
|
|
'300',
|
|
ParameterType.UNSIGNED_INT
|
|
)
|
|
)
|
|
device.setProperty(
|
|
new DeviceProperty('Device.DeviceInfo.Manufacturer', 'Sagemcom', ParameterType.STRING)
|
|
)
|
|
const context = makeContext(device)
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('GetParameterValues')
|
|
expect(result.response).toContain('Device.ManagementServer.PeriodicInformInterval')
|
|
expect(result.response).toContain('Device.DeviceInfo.Manufacturer')
|
|
})
|
|
|
|
it('REFRESH returns empty string when the device has no known parameters', async () => {
|
|
setupCommandMock([makeCommand(CommandType.REFRESH, {})])
|
|
const state = new ExecutingCommandsState(true)
|
|
// makeDevice() has no parameters/properties set beyond tmpParamValues
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toBe('')
|
|
})
|
|
|
|
it('marks the command as FAILED and returns empty for an unknown command type', async () => {
|
|
setupCommandMock([makeCommand('BOGUS_ACTION' as CommandType, {})])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toBe('')
|
|
expect(vi.mocked(CommandModel.updateCommand)).toHaveBeenCalledWith(
|
|
'cmd-1',
|
|
expect.objectContaining({ status: CommandStatus.FAILED })
|
|
)
|
|
})
|
|
|
|
it('falls through to the next command when an unknown type is followed by a known one', async () => {
|
|
setupCommandMock([
|
|
makeCommand('BOGUS_ACTION' as CommandType, {}),
|
|
makeCommand(CommandType.REBOOT, {}),
|
|
])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
expect(result.response).toContain('Reboot')
|
|
})
|
|
|
|
it('processes command response: marks COMPLETED on success and advances to next command', async () => {
|
|
setupCommandMock([
|
|
makeCommand(CommandType.REBOOT, {}),
|
|
makeCommand(CommandType.FACTORY_RESET, {}),
|
|
])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
// First call: sends REBOOT
|
|
await state.handle(context)
|
|
// Second call: simulates a successful CWMP response for REBOOT, advances to FACTORY_RESET
|
|
context.message = { success: true, data: {} }
|
|
const result = await state.handle(context)
|
|
|
|
expect(result.response).toContain('FactoryReset')
|
|
expect(vi.mocked(CommandModel.updateCommand)).toHaveBeenCalledWith(
|
|
'cmd-1',
|
|
expect.objectContaining({ status: CommandStatus.COMPLETED })
|
|
)
|
|
})
|
|
|
|
it('processes command response: marks FAILED on non-success and clears the queue if empty', async () => {
|
|
setupCommandMock([makeCommand(CommandType.REBOOT, {})])
|
|
const state = new ExecutingCommandsState(false)
|
|
const context = makeContext(makeDevice())
|
|
|
|
await state.handle(context)
|
|
context.message = { success: false, data: { FaultCode: 9001 } }
|
|
const result = await state.handle(context)
|
|
|
|
expect(result.response).toBe('')
|
|
expect(vi.mocked(CommandModel.updateCommand)).toHaveBeenCalledWith(
|
|
'cmd-1',
|
|
expect.objectContaining({ status: CommandStatus.FAILED })
|
|
)
|
|
})
|
|
|
|
it('optimistically applies SET_PARAMETER_VALUES on success (updateFromValues called)', async () => {
|
|
setupCommandMock([
|
|
makeCommand(CommandType.SET_PARAMETER_VALUES, [
|
|
{ name: 'Device.WiFi.SSID', value: 'NewWifi', type: 'xsd:string' },
|
|
]),
|
|
])
|
|
const state = new ExecutingCommandsState(true)
|
|
const device = makeDevice()
|
|
const context = makeContext(device)
|
|
|
|
await state.handle(context)
|
|
context.message = { success: true, data: {} }
|
|
await state.handle(context)
|
|
|
|
const param = device.getParameter('Device.WiFi.SSID') || device.getProperty('Device.WiFi.SSID')
|
|
expect(param?.value).toBe('NewWifi')
|
|
})
|
|
|
|
it('all commands done with includeNI=true transitions to OperationalState', async () => {
|
|
setupCommandMock([makeCommand(CommandType.REBOOT, {})])
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
await state.handle(context)
|
|
context.message = { success: true, data: {} }
|
|
const result = await state.handle(context)
|
|
|
|
expect(result.nextState?.name).toBe('operational')
|
|
})
|
|
|
|
it('all commands done with includeNI=false completes without transitioning (immediate machine)', async () => {
|
|
setupCommandMock([makeCommand(CommandType.REBOOT, {})])
|
|
const state = new ExecutingCommandsState(false)
|
|
const context = makeContext(makeDevice())
|
|
|
|
await state.handle(context)
|
|
context.message = { success: true, data: {} }
|
|
const result = await state.handle(context)
|
|
|
|
expect(result.nextState).toBeUndefined()
|
|
expect(result.response).toBe('')
|
|
})
|
|
|
|
it('tolerates CommandModel.updateCommand rejecting when marking SENT', async () => {
|
|
setupCommandMock([makeCommand(CommandType.REBOOT, {})])
|
|
vi.mocked(CommandModel.updateCommand).mockRejectedValueOnce(new Error('db error'))
|
|
const state = new ExecutingCommandsState(true)
|
|
const context = makeContext(makeDevice())
|
|
|
|
const result = await state.handle(context)
|
|
// Should not throw — error is caught and logged
|
|
expect(result.response).toContain('Reboot')
|
|
})
|
|
|
|
// --- Exercising the no-op completion callbacks registered per command type ---
|
|
// Each dispatch registers a `() => Promise.resolve('')` callback with the task
|
|
// queue; it only runs when the CWMP task lifecycle completes it.
|
|
|
|
it.each([
|
|
[CommandType.GET_PARAMETER_VALUES, ['Device.Info.SW']],
|
|
[CommandType.GET_PARAMETER_NAMES, 'Device.WiFi.'],
|
|
[CommandType.GET_PARAMETER_ATTRIBUTES, ['Device.Info.Manufacturer']],
|
|
[CommandType.SET_PARAMETER_VALUES, [{ name: 'Device.WiFi.SSID', value: 'x' }]],
|
|
[
|
|
CommandType.SET_PARAMETER_ATTRIBUTES,
|
|
[{ name: 'Device.WiFi.SSID', notification: 1, accessList: [] }],
|
|
],
|
|
[CommandType.DOWNLOAD, { fileType: '1 Firmware Upgrade Image', url: 'http://fw/x.bin' }],
|
|
[CommandType.UPLOAD, { fileType: '1 Vendor Configuration File' }],
|
|
[CommandType.REBOOT, {}],
|
|
[CommandType.FACTORY_RESET, {}],
|
|
[CommandType.SCHEDULE_INFORM, {}],
|
|
])('completes the no-op task callback for %s without throwing', async (action, parameters) => {
|
|
setupCommandMock([makeCommand(action as CommandType, parameters)])
|
|
const state = new ExecutingCommandsState(true)
|
|
const device = makeDevice()
|
|
const context = makeContext(device)
|
|
|
|
await state.handle(context)
|
|
|
|
const taskId = context.taskQueue.getLastCreatedTaskId(device.deviceId)
|
|
expect(taskId).toBeDefined()
|
|
|
|
const response = await context.taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, {})
|
|
expect(response).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('MainStateMachine — additional coverage (DiscoveringParameters, GPN flows)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('sends GPN for tr-098 device when gpnStatus=unknown', async () => {
|
|
const device = new Device(new DeviceId('Huawei', '202BC1', 'BM632w', 'SN001'))
|
|
device.setDataModel('tr-098')
|
|
device.setGpnStatus('unknown')
|
|
device.setTmpParamValues([
|
|
{ Name: 'InternetGatewayDevice.ManagementServer.URL', Value: { _: 'http://acs/' } },
|
|
])
|
|
const taskQueue = new CWMPTaskQueue()
|
|
const sm = new MainStateMachine(device, 'sess', taskQueue)
|
|
|
|
const response = await sm.start()
|
|
expect(response).toContain('GetParameterNames')
|
|
expect(response).toContain('InternetGatewayDevice.ManagementServer.')
|
|
})
|
|
|
|
it('skips GPN for unknown dataModel and goes to critical-reads', async () => {
|
|
const device = new Device(new DeviceId('Unknown', '000000', 'Generic', 'SN002'))
|
|
device.setDataModel('unknown')
|
|
device.setGpnStatus('unknown')
|
|
const taskQueue = new CWMPTaskQueue()
|
|
const sm = new MainStateMachine(device, 'sess', taskQueue)
|
|
|
|
const response = await sm.start()
|
|
// Should go to critical-reads (GPV) since unknown data model skips GPN
|
|
expect(response).toContain('GetParameterValues')
|
|
})
|
|
|
|
it('DiscoveringParametersState handles GPN success and transitions to CriticalReads', async () => {
|
|
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.ManagementServer. (single test path)
|
|
await sm.start()
|
|
|
|
// 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')
|
|
})
|
|
|
|
it('handles MAX_STATE_TRANSITIONS exceeded gracefully', async () => {
|
|
// Create a state machine with a device that will loop
|
|
const device = makeDevice('unsupported')
|
|
const taskQueue = new CWMPTaskQueue()
|
|
const sm = new MainStateMachine(device, 'sess', taskQueue)
|
|
|
|
// The machine won't actually loop 15 times in normal flow since it completes
|
|
// Just verify the machine starts and runs without crashing
|
|
const response = await sm.start()
|
|
expect(response).toBeDefined()
|
|
})
|
|
|
|
it('forces max-transitions-exceeded and transitions to PartialOperationalState', async () => {
|
|
const device = makeDevice('unsupported')
|
|
const taskQueue = new CWMPTaskQueue()
|
|
const sm = new MainStateMachine(device, 'sess', taskQueue)
|
|
|
|
// Inject a state that always transitions to a fresh instance of itself,
|
|
// never producing a response nor completing — forces the 15-iteration guard.
|
|
class LoopingState {
|
|
name = 'looping'
|
|
async handle() {
|
|
return { nextState: new LoopingState() as any }
|
|
}
|
|
}
|
|
;(sm as any).currentState = new LoopingState()
|
|
|
|
const response = await sm.start()
|
|
|
|
expect(response).toBe('')
|
|
expect(sm.getCurrentStateName()).toBe('partial_operational')
|
|
expect(sm.isCompleted()).toBe(true)
|
|
expect(device.lastFailureReason).toBe('max_transitions_exceeded')
|
|
})
|
|
|
|
it('transitions to PartialOperationalState when a state handler throws', async () => {
|
|
const device = makeDevice('unsupported')
|
|
const taskQueue = new CWMPTaskQueue()
|
|
const sm = new MainStateMachine(device, 'sess', taskQueue)
|
|
|
|
class ThrowingState {
|
|
name = 'throwing'
|
|
async handle(): Promise<any> {
|
|
throw new Error('boom in state handler')
|
|
}
|
|
}
|
|
;(sm as any).currentState = new ThrowingState()
|
|
|
|
const response = await sm.start()
|
|
|
|
expect(response).toBe('')
|
|
expect(sm.getCurrentStateName()).toBe('partial_operational')
|
|
expect(device.lastFailureReason).toBe('unrecoverable_error')
|
|
})
|
|
|
|
it('returns empty string when a state produces neither response, nextState, nor isComplete', async () => {
|
|
const device = makeDevice('unsupported')
|
|
const taskQueue = new CWMPTaskQueue()
|
|
const sm = new MainStateMachine(device, 'sess', taskQueue)
|
|
|
|
class DeadEndState {
|
|
name = 'dead-end'
|
|
async handle(): Promise<any> {
|
|
return {}
|
|
}
|
|
}
|
|
;(sm as any).currentState = new DeadEndState()
|
|
|
|
const response = await sm.start()
|
|
|
|
expect(response).toBe('')
|
|
expect(sm.getCurrentStateName()).toBe('dead-end')
|
|
})
|
|
})
|
|
|
|
describe('OperationalState', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('persists device with operational status', async () => {
|
|
const device = makeDevice()
|
|
const taskQueue = new CWMPTaskQueue()
|
|
const context = {
|
|
device,
|
|
session: 'test-session',
|
|
taskQueue,
|
|
services: { deviceAction: {} as any },
|
|
}
|
|
|
|
const state = new OperationalState()
|
|
const result = await state.handle(context as any)
|
|
|
|
expect(result.isComplete).toBe(true)
|
|
expect(device.operationalStatus).toBe('operational')
|
|
expect(vi.mocked(DeviceModel.createOrUpdate)).toHaveBeenCalledWith(
|
|
expect.objectContaining({ operationalStatus: 'operational' })
|
|
)
|
|
})
|
|
|
|
it('still completes when DeviceModel.createOrUpdate rejects (persistence error tolerated)', async () => {
|
|
const device = makeDevice()
|
|
const taskQueue = new CWMPTaskQueue()
|
|
const context = {
|
|
device,
|
|
session: 'test-session',
|
|
taskQueue,
|
|
services: { deviceAction: {} as any },
|
|
}
|
|
vi.mocked(DeviceModel.createOrUpdate).mockRejectedValueOnce(new Error('db unavailable'))
|
|
|
|
const state = new OperationalState()
|
|
const result = await state.handle(context as any)
|
|
|
|
expect(result.isComplete).toBe(true)
|
|
expect(device.operationalStatus).toBe('operational')
|
|
})
|
|
})
|
|
|
|
describe('PartialOperationalState', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('persists device with partial_operational status and failure reason', async () => {
|
|
const device = makeDevice()
|
|
const taskQueue = new CWMPTaskQueue()
|
|
const context = {
|
|
device,
|
|
session: 'test-session',
|
|
taskQueue,
|
|
services: { deviceAction: {} as any },
|
|
}
|
|
|
|
const state = new PartialOperationalState(device, {
|
|
reason: 'test_failure',
|
|
blockingState: 'some-state',
|
|
})
|
|
const result = await state.handle(context as any)
|
|
|
|
expect(result.isComplete).toBe(true)
|
|
expect(device.operationalStatus).toBe('partial_operational')
|
|
expect(device.lastFailureReason).toBe('test_failure')
|
|
expect(vi.mocked(DeviceModel.createOrUpdate)).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
operationalStatus: 'partial_operational',
|
|
lastFailureReason: 'test_failure',
|
|
})
|
|
)
|
|
})
|
|
|
|
it('still completes when DeviceModel.createOrUpdate rejects (persistence error tolerated)', async () => {
|
|
const device = makeDevice()
|
|
const taskQueue = new CWMPTaskQueue()
|
|
const context = {
|
|
device,
|
|
session: 'test-session',
|
|
taskQueue,
|
|
services: { deviceAction: {} as any },
|
|
}
|
|
vi.mocked(DeviceModel.createOrUpdate).mockRejectedValueOnce(new Error('db unavailable'))
|
|
|
|
const state = new PartialOperationalState(device, { reason: 'test_failure' })
|
|
const result = await state.handle(context as any)
|
|
|
|
expect(result.isComplete).toBe(true)
|
|
expect(device.operationalStatus).toBe('partial_operational')
|
|
})
|
|
})
|