fix: allow uppercase letters in configuration identifier regex
Previously /^[a-z0-9]+(-[a-z0-9]+)*$/ rejected identifiers like 'BM632w-standard'. Updated to /^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/ to allow mixed case. Generated with [Continue](https://continue.dev) Co-Authored-By: Continue <noreply@continue.dev>
This commit is contained in:
1 parent
30211d278d
commit
ca24b469d3
4 files changed
+712
-1
No files matched your search
@@ -37,7 +37,7 @@ const scheduleWindowSchema = vine.object({
|
||||
|
||||
export const createConfigurationValidator = vine.compile(
|
||||
vine.object({
|
||||
identifier: vine.string().trim().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/),
|
||||
identifier: vine.string().trim().regex(/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/),
|
||||
name: vine.string().trim().minLength(1),
|
||||
version: vine.string().trim().regex(/^\d+\.\d+\.\d+$/),
|
||||
priority: vine.number().positive(),
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
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),
|
||||
findOne: vi.fn().mockResolvedValue(null),
|
||||
updateOne: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock ConfigurationModel — dynamically controlled per test
|
||||
vi.mock('#models/configuration', () => ({
|
||||
default: {
|
||||
find: vi.fn().mockResolvedValue([]),
|
||||
findOne: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock FirmwareCatalogService
|
||||
vi.mock('../../../app/services/firmware_catalog_service.js', () => ({
|
||||
default: class { async resolveUpgradePath() { return [] } },
|
||||
FirmwareCatalogService: class { async resolveUpgradePath() { return [] } },
|
||||
}))
|
||||
|
||||
// Mock schedule_window
|
||||
vi.mock('../../../app/utils/schedule_window.js', () => ({
|
||||
isWithinSchedule: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
import ConfigurationEngine from '../../../app/services/configuration_engine.js'
|
||||
import { Device } from '../../../app/domain/device/device.js'
|
||||
import { DeviceId } from '../../../app/domain/device/device_id.js'
|
||||
import { DeviceProperty, DeviceParameter } from '../../../app/domain/device/device_parameter.js'
|
||||
import { ParameterType } from '../../../app/domain/shared/types.js'
|
||||
import ConfigurationModel from '#models/configuration'
|
||||
|
||||
function makeDevice(opts: { manufacturer?: string; productClass?: string; provisioningCode?: string } = {}): Device {
|
||||
const device = new Device(new DeviceId(
|
||||
opts.manufacturer || 'Sagemcom',
|
||||
'F8B7FF',
|
||||
opts.productClass || 'Livebox 4',
|
||||
'LB4-TEST-001'
|
||||
))
|
||||
device.setDataModel('tr-181')
|
||||
if (opts.provisioningCode) {
|
||||
(device as any).provisioningCode = opts.provisioningCode
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
||||
function makeConfig(overrides: any = {}) {
|
||||
return {
|
||||
identifier: 'test-config',
|
||||
name: 'Test Config',
|
||||
version: '1.0.0',
|
||||
priority: 1,
|
||||
policy: 'maintain',
|
||||
enabled: true,
|
||||
criteria: { manufacturer: 'Sagemcom', productClass: 'Livebox 4' },
|
||||
inventory: [{ path: 'Device.ManagementServer.' }],
|
||||
desiredParameters: [
|
||||
{ name: 'Device.ManagementServer.PeriodicInformInterval', value: '300', type: 'unsignedInt' },
|
||||
],
|
||||
firmwareTarget: null,
|
||||
schedule: null,
|
||||
tags: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConfigurationEngine — coverage', () => {
|
||||
let engine: ConfigurationEngine
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
engine = new ConfigurationEngine()
|
||||
})
|
||||
|
||||
// --- evaluate() ---
|
||||
|
||||
it('returns isAlreadyCompliant when no configs exist', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.isAlreadyCompliant).toBe(true)
|
||||
expect(result.configuration).toBeNull()
|
||||
})
|
||||
|
||||
it('returns isAlreadyCompliant when no config matches criteria', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ criteria: { manufacturer: 'Huawei' } })])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.isAlreadyCompliant).toBe(true)
|
||||
expect(result.configuration).toBeNull()
|
||||
})
|
||||
|
||||
it('selects highest priority config when multiple match', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([
|
||||
makeConfig({ identifier: 'low', priority: 1 }),
|
||||
makeConfig({ identifier: 'high', priority: 10 }),
|
||||
makeConfig({ identifier: 'mid', priority: 5 }),
|
||||
])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.configuration?.identifier).toBe('high')
|
||||
})
|
||||
|
||||
it('returns parameterDiffs when device value differs from desired', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig()])
|
||||
const device = makeDevice()
|
||||
// Set a different value
|
||||
device.setParameter(new DeviceParameter(
|
||||
'Device.ManagementServer.PeriodicInformInterval',
|
||||
'60',
|
||||
ParameterType.UNSIGNED_INT
|
||||
))
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.parameterDiffs.length).toBe(1)
|
||||
expect(result.parameterDiffs[0].currentValue).toBe('60')
|
||||
expect(result.parameterDiffs[0].targetValue).toBe('300')
|
||||
expect(result.isAlreadyCompliant).toBe(false)
|
||||
})
|
||||
|
||||
it('returns empty diffs when device value matches desired', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig()])
|
||||
const device = makeDevice()
|
||||
device.setParameter(new DeviceParameter(
|
||||
'Device.ManagementServer.PeriodicInformInterval',
|
||||
'300',
|
||||
ParameterType.UNSIGNED_INT
|
||||
))
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.parameterDiffs.length).toBe(0)
|
||||
expect(result.isAlreadyCompliant).toBe(true)
|
||||
})
|
||||
|
||||
it('treats null current value as diff', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig()])
|
||||
const device = makeDevice()
|
||||
// Don't set the parameter — it's unknown
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.parameterDiffs.length).toBe(1)
|
||||
expect(result.parameterDiffs[0].currentValue).toBeNull()
|
||||
})
|
||||
|
||||
// --- matchesCriteria ---
|
||||
|
||||
it('matchesCriteria matches glob patterns on manufacturer', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ criteria: { manufacturer: 'Sage*' } })])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.configuration).not.toBeNull()
|
||||
})
|
||||
|
||||
it('matchesCriteria matches wildcard * on productClass', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ criteria: { manufacturer: 'Sagemcom', productClass: '*' } })])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.configuration).not.toBeNull()
|
||||
})
|
||||
|
||||
it('matchesCriteria rejects when parameterCriteria value mismatches', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({
|
||||
criteria: {
|
||||
manufacturer: 'Sagemcom',
|
||||
parameterCriteria: [{ name: 'Device.DeviceInfo.ModelName', value: 'WrongModel' }],
|
||||
},
|
||||
})])
|
||||
const device = makeDevice()
|
||||
device.setParameter(new DeviceParameter('Device.DeviceInfo.ModelName', 'Livebox 4', ParameterType.STRING))
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.configuration).toBeNull()
|
||||
expect(result.isAlreadyCompliant).toBe(true)
|
||||
})
|
||||
|
||||
it('matchesCriteria rejects when parameterCriteria param is missing on device', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({
|
||||
criteria: {
|
||||
manufacturer: 'Sagemcom',
|
||||
parameterCriteria: [{ name: 'Device.DeviceInfo.ModelName', value: 'Livebox 4' }],
|
||||
},
|
||||
})])
|
||||
const device = makeDevice()
|
||||
// Don't set ModelName → param is missing
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.configuration).toBeNull()
|
||||
})
|
||||
|
||||
// --- shouldApplyOnce (policy: once) ---
|
||||
|
||||
it('shouldApplyOnce returns true (re-apply) when no provisioningCode on device', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ policy: 'once' })])
|
||||
const device = makeDevice()
|
||||
const result = await engine.evaluate(device)
|
||||
// Should evaluate normally (not skip)
|
||||
expect(result.configuration).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shouldApplyOnce returns false (skip) when provisioningCode hash matches', async () => {
|
||||
const config = makeConfig({ policy: 'once' })
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([config])
|
||||
|
||||
const device = makeDevice()
|
||||
// Compute the expected provisioning code
|
||||
const { buildProvisioningCode } = await import('../../../app/utils/provisioning_code.js')
|
||||
const expectedPC = buildProvisioningCode(config as any)
|
||||
;(device as any).provisioningCode = expectedPC
|
||||
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.isAlreadyCompliant).toBe(true)
|
||||
expect(result.parameterDiffs.length).toBe(0)
|
||||
})
|
||||
|
||||
// --- Numeric and boolean comparison ---
|
||||
|
||||
it('computeParameterDiffs handles numeric comparison (unsignedInt)', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({
|
||||
desiredParameters: [{ name: 'Device.X.Interval', value: '300', type: 'unsignedInt' }],
|
||||
})])
|
||||
const device = makeDevice()
|
||||
device.setParameter(new DeviceParameter('Device.X.Interval', '300', ParameterType.UNSIGNED_INT))
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.parameterDiffs.length).toBe(0) // 300 == 300
|
||||
})
|
||||
|
||||
it('computeParameterDiffs handles boolean comparison', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({
|
||||
desiredParameters: [{ name: 'Device.X.Enabled', value: 'true', type: 'boolean' }],
|
||||
})])
|
||||
const device = makeDevice()
|
||||
device.setParameter(new DeviceParameter('Device.X.Enabled', '1', ParameterType.BOOLEAN))
|
||||
const result = await engine.evaluate(device)
|
||||
// '1' and 'true' should be treated as same boolean
|
||||
expect(result.parameterDiffs.length).toBe(0)
|
||||
})
|
||||
|
||||
it('computeParameterDiffs detects boolean mismatch', async () => {
|
||||
vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({
|
||||
desiredParameters: [{ name: 'Device.X.Enabled', value: 'true', type: 'boolean' }],
|
||||
})])
|
||||
const device = makeDevice()
|
||||
device.setParameter(new DeviceParameter('Device.X.Enabled', 'false', ParameterType.BOOLEAN))
|
||||
const result = await engine.evaluate(device)
|
||||
expect(result.parameterDiffs.length).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
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({}),
|
||||
},
|
||||
}))
|
||||
|
||||
import { DeviceActionExecutor } from '../../../../app/services/cwmp/device_action_executor.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, DeviceParameter } from '../../../../app/domain/device/device_parameter.js'
|
||||
import { ParameterType } from '../../../../app/domain/shared/types.js'
|
||||
import DeviceModel from '../../../../app/models/device.js'
|
||||
|
||||
function makeDevice(dataModel: string = 'tr-181'): Device {
|
||||
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
|
||||
device.setDataModel(dataModel as any)
|
||||
return device
|
||||
}
|
||||
|
||||
describe('DeviceActionExecutor — coverage', () => {
|
||||
let taskQueue: CWMPTaskQueue
|
||||
let executor: DeviceActionExecutor
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
taskQueue = new CWMPTaskQueue()
|
||||
executor = new DeviceActionExecutor(taskQueue)
|
||||
})
|
||||
|
||||
// --- getParameterValues ---
|
||||
|
||||
it('getParameterValues returns XML containing GetParameterValues', async () => {
|
||||
const device = makeDevice()
|
||||
const xml = await executor.getParameterValues(device, ['Device.Info.SW'], async () => '')
|
||||
expect(xml).toContain('GetParameterValues')
|
||||
expect(xml).toContain('Device.Info.SW')
|
||||
})
|
||||
|
||||
it('getParameterValues wrappedCallback updates device on success', async () => {
|
||||
const device = makeDevice()
|
||||
let capturedCallback: any
|
||||
const origAddTask = taskQueue.addTask.bind(taskQueue)
|
||||
vi.spyOn(taskQueue, 'addTask').mockImplementation((deviceId, type, props, cb) => {
|
||||
capturedCallback = cb
|
||||
return origAddTask(deviceId, type, props, cb)
|
||||
})
|
||||
|
||||
await executor.getParameterValues(device, ['Device.Info.SW'], async () => '')
|
||||
|
||||
// Execute the wrapped callback with success
|
||||
await capturedCallback({
|
||||
status: ResultStatus.Success,
|
||||
data: {
|
||||
ParameterList: {
|
||||
ParameterValueStruct: [
|
||||
{ Name: 'Device.Info.SW', Value: { _: '5.0.1', $: { Type: 'xsd:string' } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Device should have been updated
|
||||
const prop = device.getProperty('Device.Info.SW')
|
||||
expect(prop).not.toBeNull()
|
||||
expect(prop!.value).toBe('5.0.1')
|
||||
// Should have persisted
|
||||
expect(mockCreateOrUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// --- getParameterNames ---
|
||||
|
||||
it('getParameterNames returns GPN envelope', async () => {
|
||||
const device = makeDevice()
|
||||
const xml = await executor.getParameterNames(device, 'Device.ManagementServer.', async () => '')
|
||||
expect(xml).toContain('GetParameterNames')
|
||||
expect(xml).toContain('Device.ManagementServer.')
|
||||
})
|
||||
|
||||
it('GPN success marks device as supported and records success', async () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('unknown')
|
||||
let capturedCallback: any
|
||||
vi.spyOn(taskQueue, 'addTask').mockImplementation((deviceId, type, props, cb) => {
|
||||
capturedCallback = cb
|
||||
return { xml: '<gpn/>', taskId: 'tid-1' }
|
||||
})
|
||||
|
||||
await executor.getParameterNames(device, 'Device.', async () => '')
|
||||
await capturedCallback({ status: ResultStatus.Success, data: { ParameterList: { ParameterInfoStruct: [] } } })
|
||||
|
||||
expect(device.gpnStatus).toBe('supported')
|
||||
expect(device.gpnCircuitBreaker.failureCount).toBe(0)
|
||||
})
|
||||
|
||||
it('GPN fault 9001 marks device as unsupported and records failure', async () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('unknown')
|
||||
let capturedCallback: any
|
||||
vi.spyOn(taskQueue, 'addTask').mockImplementation((deviceId, type, props, cb) => {
|
||||
capturedCallback = cb
|
||||
return { xml: '<gpn/>', taskId: 'tid-2' }
|
||||
})
|
||||
|
||||
await executor.getParameterNames(device, 'Device.', async () => '')
|
||||
await capturedCallback({ status: ResultStatus.Error, data: { FaultCode: '9001', FaultString: 'Not supported' } })
|
||||
|
||||
expect(device.gpnStatus).toBe('unsupported')
|
||||
expect(device.gpnCircuitBreaker.failureCount).toBe(1)
|
||||
})
|
||||
|
||||
it('GPN fault 9005 marks device as unsupported', async () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('unknown')
|
||||
let capturedCallback: any
|
||||
vi.spyOn(taskQueue, 'addTask').mockImplementation((deviceId, type, props, cb) => {
|
||||
capturedCallback = cb
|
||||
return { xml: '<gpn/>', taskId: 'tid-3' }
|
||||
})
|
||||
|
||||
await executor.getParameterNames(device, 'Device.', async () => '')
|
||||
await capturedCallback({ status: ResultStatus.Error, data: { FaultCode: 9005 } })
|
||||
|
||||
expect(device.gpnStatus).toBe('unsupported')
|
||||
expect(device.gpnCircuitBreaker.failureCount).toBe(1)
|
||||
})
|
||||
|
||||
it('GPN fault 9002 is treated as transient (no unsupported mark)', async () => {
|
||||
const device = makeDevice()
|
||||
device.setGpnStatus('unknown')
|
||||
let capturedCallback: any
|
||||
vi.spyOn(taskQueue, 'addTask').mockImplementation((deviceId, type, props, cb) => {
|
||||
capturedCallback = cb
|
||||
return { xml: '<gpn/>', taskId: 'tid-4' }
|
||||
})
|
||||
|
||||
await executor.getParameterNames(device, 'Device.', async () => '')
|
||||
await capturedCallback({ status: ResultStatus.Error, data: { FaultCode: '9002' } })
|
||||
|
||||
// Should NOT mark as unsupported
|
||||
expect(device.gpnStatus).toBe('unknown')
|
||||
expect(device.gpnCircuitBreaker.failureCount).toBe(0)
|
||||
})
|
||||
|
||||
// --- applySetParameterValues ---
|
||||
|
||||
it('applySetParameterValues returns SPV envelope', async () => {
|
||||
const device = makeDevice()
|
||||
const xml = await executor.applySetParameterValues(
|
||||
device,
|
||||
[{ parameterName: 'Device.X.Interval', value: '300', type: 'unsignedInt' }],
|
||||
async () => ''
|
||||
)
|
||||
expect(xml).toContain('SetParameterValues')
|
||||
expect(xml).toContain('Device.X.Interval')
|
||||
expect(xml).toContain('300')
|
||||
expect(xml).toContain('xsd:unsignedInt')
|
||||
})
|
||||
|
||||
it('applySetParameterValues infers type from device param when not specified', async () => {
|
||||
const device = makeDevice()
|
||||
device.setParameter(new DeviceParameter('Device.X.Flag', 'true', ParameterType.BOOLEAN))
|
||||
|
||||
const xml = await executor.applySetParameterValues(
|
||||
device,
|
||||
[{ parameterName: 'Device.X.Flag', value: 'false' }],
|
||||
async () => ''
|
||||
)
|
||||
expect(xml).toContain('xsd:boolean')
|
||||
})
|
||||
|
||||
it('applySetParameterValues defaults to xsd:string for unknown type', async () => {
|
||||
const device = makeDevice()
|
||||
const xml = await executor.applySetParameterValues(
|
||||
device,
|
||||
[{ parameterName: 'Device.X.New', value: 'hello' }],
|
||||
async () => ''
|
||||
)
|
||||
expect(xml).toContain('xsd:string')
|
||||
})
|
||||
|
||||
// --- insertProvisioningCode ---
|
||||
|
||||
it('insertProvisioningCode uses tr-181 path for tr-181 device', async () => {
|
||||
const device = makeDevice('tr-181')
|
||||
device.tmpPC = 'my-prov-code'
|
||||
const xml = await executor.insertProvisioningCode(device, async () => '')
|
||||
expect(xml).toContain('Device.DeviceInfo.ProvisioningCode')
|
||||
expect(xml).toContain('my-prov-code')
|
||||
})
|
||||
|
||||
it('insertProvisioningCode uses tr-098 path for tr-098 device', async () => {
|
||||
const device = makeDevice('tr-098')
|
||||
device.tmpPC = 'code-098'
|
||||
const xml = await executor.insertProvisioningCode(device, async () => '')
|
||||
expect(xml).toContain('InternetGatewayDevice.DeviceInfo.ProvisioningCode')
|
||||
})
|
||||
|
||||
it('insertProvisioningCode uses known param path when available', async () => {
|
||||
const device = makeDevice('tr-181')
|
||||
device.setProperty(new DeviceProperty('Device.DeviceInfo.ProvisioningCode', 'old-code', ParameterType.STRING))
|
||||
device.tmpPC = 'new-code'
|
||||
const xml = await executor.insertProvisioningCode(device, async () => '')
|
||||
expect(xml).toContain('Device.DeviceInfo.ProvisioningCode')
|
||||
expect(xml).toContain('new-code')
|
||||
})
|
||||
|
||||
// --- persistDevice ---
|
||||
|
||||
it('persistDevice calls DeviceModel.createOrUpdate', async () => {
|
||||
const device = makeDevice()
|
||||
let capturedCallback: any
|
||||
vi.spyOn(taskQueue, 'addTask').mockImplementation((deviceId, type, props, cb) => {
|
||||
capturedCallback = cb
|
||||
return { xml: '<xml/>', taskId: 'tid-p' }
|
||||
})
|
||||
|
||||
await executor.getParameterValues(device, ['Device.X'], async () => '')
|
||||
await capturedCallback({
|
||||
status: ResultStatus.Success,
|
||||
data: { ParameterList: { ParameterValueStruct: [{ Name: 'Device.X', Value: { _: 'v' } }] } },
|
||||
})
|
||||
|
||||
expect(vi.mocked(DeviceModel.createOrUpdate)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ identifier: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
// --- Download/Reboot ---
|
||||
|
||||
it('applyDownload returns Download envelope', async () => {
|
||||
const device = makeDevice()
|
||||
const xml = await executor.applyDownload(device, {
|
||||
commandKey: 'fw-1',
|
||||
fileType: '1 Firmware Upgrade Image',
|
||||
url: 'http://example.com/fw.bin',
|
||||
}, async () => '')
|
||||
expect(xml).toContain('Download')
|
||||
expect(xml).toContain('http://example.com/fw.bin')
|
||||
})
|
||||
|
||||
it('applyReboot returns Reboot envelope', async () => {
|
||||
const device = makeDevice()
|
||||
const xml = await executor.applyReboot(device, { commandKey: 'reboot-1' }, async () => '')
|
||||
expect(xml).toContain('Reboot')
|
||||
expect(xml).toContain('reboot-1')
|
||||
})
|
||||
|
||||
it('applyFactoryReset returns FactoryReset envelope', async () => {
|
||||
const device = makeDevice()
|
||||
const xml = await executor.applyFactoryReset(device, {}, async () => '')
|
||||
expect(xml).toContain('FactoryReset')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
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'
|
||||
import { ResultStatus } from '../../../../app/services/cwmp/task_queue.js'
|
||||
import CommandModel from '../../../../app/models/command.js'
|
||||
|
||||
function makeDevice(gpnStatus: string = 'unsupported'): Device {
|
||||
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
|
||||
device.setDataModel('tr-181')
|
||||
device.setGpnStatus(gpnStatus as any)
|
||||
return device
|
||||
}
|
||||
|
||||
describe('StateMachineManager — coverage', () => {
|
||||
let manager: StateMachineManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(StateMachineManager as any).instance = null
|
||||
manager = StateMachineManager.getInstance()
|
||||
})
|
||||
|
||||
// --- Event routing ---
|
||||
|
||||
it('routes BOOTSTRAP event to MainStateMachine', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-1', { events: ['0 BOOTSTRAP'] })
|
||||
expect(machine.status).toBe(MachineStatus.RUNNING)
|
||||
// MainStateMachine has getCurrentStateName
|
||||
expect(machine.stateMachine.getCurrentStateName()).toBeDefined()
|
||||
})
|
||||
|
||||
it('routes CONNECTION_REQUEST to ImmediateCommandStateMachine', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-2', { events: ['6 CONNECTION REQUEST'] })
|
||||
expect(machine.status).toBe(MachineStatus.RUNNING)
|
||||
expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands')
|
||||
})
|
||||
|
||||
it('routes PERIODIC to MainStateMachine', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-3', { events: ['2 PERIODIC'] })
|
||||
expect(machine.status).toBe(MachineStatus.RUNNING)
|
||||
expect(machine.stateMachine.getCurrentStateName()).toBe('idle')
|
||||
})
|
||||
|
||||
it('forces MainStateMachine when NI commands exist', async () => {
|
||||
// Override the mock to return count > 0
|
||||
const collection = CommandModel.getCollection()
|
||||
vi.mocked(collection.countDocuments).mockResolvedValueOnce(3)
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-4', { events: ['6 CONNECTION REQUEST'] })
|
||||
// Should be MainStateMachine (idle) not ImmediateCommandStateMachine
|
||||
expect(machine.stateMachine.getCurrentStateName()).toBe('idle')
|
||||
})
|
||||
|
||||
it('preempts ImmediateCommandStateMachine on BOOT event', async () => {
|
||||
const device = makeDevice()
|
||||
// First: create ImmediateCommandStateMachine
|
||||
const machine1 = await manager.startStateMachine(device, 'sess-5', { events: ['6 CONNECTION REQUEST'] })
|
||||
expect(machine1.stateMachine.getCurrentStateName()).toBe('executing-commands')
|
||||
|
||||
// Second: BOOT event arrives on same session
|
||||
const machine2 = await manager.startStateMachine(device, 'sess-5', { events: ['1 BOOT'] })
|
||||
// Should have preempted to MainStateMachine
|
||||
expect(machine2.stateMachine.getCurrentStateName()).toBe('idle')
|
||||
})
|
||||
|
||||
// --- continue() ---
|
||||
|
||||
it('continue() calls handleResponse on state machine', async () => {
|
||||
const device = makeDevice()
|
||||
await manager.startStateMachine(device, 'sess-6', { events: ['0 BOOTSTRAP'] })
|
||||
// continue sends empty POST handling
|
||||
const response = await manager.continue('sess-6')
|
||||
// Should have produced a GPV (critical reads since gpn=unsupported)
|
||||
expect(response).toContain('GetParameterValues')
|
||||
})
|
||||
|
||||
it('continue() treats empty body as task failure when lastTaskId is set', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-7', { events: ['0 BOOTSTRAP'] })
|
||||
// Manually set lastTaskId to simulate in-flight task
|
||||
machine.lastTaskId = 'fake-task-id'
|
||||
// Add a fake task to the queue so failTask doesn't error
|
||||
manager.getTaskQueue().addCWMPTask(device.deviceId, 'get_parameter_values', [])
|
||||
|
||||
const response = await manager.continue('sess-7')
|
||||
// Should have treated as failure and advanced the state machine
|
||||
expect(machine.lastTaskId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('continue() returns empty for unknown session', async () => {
|
||||
const response = await manager.continue('unknown-session')
|
||||
expect(response).toBe('')
|
||||
})
|
||||
|
||||
// --- handleCWMPResponse() ---
|
||||
|
||||
it('handleCWMPResponse() correlates response with lastTaskId and executes callback', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-8', { events: ['0 BOOTSTRAP'] })
|
||||
|
||||
// Start state machine (sends GPV)
|
||||
await manager.continue('sess-8')
|
||||
|
||||
// Manually set lastTaskId
|
||||
const taskId = 'test-task-123'
|
||||
machine.lastTaskId = taskId
|
||||
const callbackFn = vi.fn().mockResolvedValue('')
|
||||
manager.getTaskQueue()['taskCallbacks'].set(taskId, callbackFn)
|
||||
manager.getTaskQueue()['tasks'].set(taskId, { id: taskId, deviceId: device.deviceId, type: 'get_parameter_values', payload: [], status: 'SENT' as any, createdAt: new Date() })
|
||||
|
||||
// Send success response
|
||||
await manager.handleCWMPResponse('sess-8', true, { ParameterList: { ParameterValueStruct: [] } })
|
||||
|
||||
// Callback should have been invoked
|
||||
expect(callbackFn).toHaveBeenCalled()
|
||||
expect(machine.lastTaskId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handleCWMPResponse() returns empty for unknown session', async () => {
|
||||
const response = await manager.handleCWMPResponse('no-such-session', true, {})
|
||||
expect(response).toBe('')
|
||||
})
|
||||
|
||||
// --- Session helpers ---
|
||||
|
||||
it('hasSession returns false for unknown session', () => {
|
||||
expect(manager.hasSession('nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
it('hasSession returns true for registered session', async () => {
|
||||
const device = makeDevice()
|
||||
await manager.startStateMachine(device, 'sess-9', { events: ['0 BOOTSTRAP'] })
|
||||
expect(manager.hasSession('sess-9')).toBe(true)
|
||||
})
|
||||
|
||||
it('ensureSessionForRemote rebinds session correctly', async () => {
|
||||
const device = makeDevice()
|
||||
await manager.startStateMachine(device, 'sess-10', { events: ['0 BOOTSTRAP'] })
|
||||
manager.bindRemoteToDevice('remote-1', device.deviceId)
|
||||
|
||||
const result = manager.ensureSessionForRemote('remote-1', 'sess-10-new')
|
||||
expect(result).toBe(true)
|
||||
expect(manager.hasSession('sess-10-new')).toBe(true)
|
||||
expect(manager.hasSession('sess-10')).toBe(false)
|
||||
})
|
||||
|
||||
it('ensureSessionForRemote returns false for unknown remote', () => {
|
||||
expect(manager.ensureSessionForRemote('unknown-remote', 'sess')).toBe(false)
|
||||
})
|
||||
|
||||
// --- Cleanup ---
|
||||
|
||||
it('cleanupCompletedMachines removes old completed machines', async () => {
|
||||
const device = makeDevice()
|
||||
const machine = await manager.startStateMachine(device, 'sess-11', { events: ['0 BOOTSTRAP'] })
|
||||
// Force complete
|
||||
;(machine as any).status = MachineStatus.COMPLETED
|
||||
;(machine as any).completedAt = new Date(Date.now() - 2 * 60 * 60 * 1000) // 2h ago
|
||||
|
||||
manager.cleanupCompletedMachines(1)
|
||||
// Machine should be removed from internal map
|
||||
expect(manager.hasSession('sess-11')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user