fix: state machine timing + reclassification tests

- Fix state machine start timing: don't call start() in startStateMachine,
  let the CPE's first empty body trigger the state machine via continue().
  This ensures GPN is sent at the correct point in the TR-069 session.
- Add unit tests for reclassifyDeviceFromCatalog (10 tests)
- Add unit tests for MainStateMachine transitions and GPN callbacks (11 tests)
- Test both scenarios: Huawei (GPN supported) and Livebox (GPN unsupported + fallback)
- Fix tr069_trace_service test path assertion (config/ → data/)
- Update steering doc with Livebox bootstrap command
This commit is contained in:
Kevin L'Hereec committed 2026-06-01 17:31:33 +02:00
1 parent a5b3135396
commit bc9ac4f125
5 files changed
+532 -10

No files matched your search

+65 -5
View File
@@ -78,7 +78,18 @@ curl -s -X PUT "http://localhost:9898/api/v1/device-simulator/create-devices?noO
}'
```
### 6. Attendre l'Inform (30s max) puis vérifier
### 6. Déclencher le bootstrap Inform
Le simulateur ne fait PAS d'Inform automatiquement après la création. Il faut le déclencher manuellement :
```bash
sleep 3
curl -s -X POST "http://localhost:9898/api/v1/device-simulator/device-request" \
-H 'Content-Type: application/json' \
-d '{"actions":[{"macaddress":"F8:B7:FF:00:11:22","event":{"0":"BOOTSTRAP"}}]}'
```
### 7. Attendre l'Inform (30s max) puis vérifier
```bash
sleep 30
@@ -99,7 +110,7 @@ print('Dans parameters (BON):', params)
"
```
### 7. Regarder les logs de reclassification
### 8. Regarder les logs de reclassification
```bash
docker compose logs app --tail 50 2>&1 | grep -i "reclassify\|Reclassify"
@@ -107,10 +118,19 @@ docker compose logs app --tail 50 2>&1 | grep -i "reclassify\|Reclassify"
## Résultat attendu
**Sagemcom Livebox 4** (GPN unsupported, reclassification via catalogue) :
```
gpnStatus: unsupported
Dans properties (MAUVAIS): []
Dans parameters (BON): ['Device.ManagementServer.PeriodicInformInterval', 'Device.DeviceInfo.ProvisioningCode']
properties: 7
parameters: 2 (PeriodicInformInterval + ProvisioningCode)
```
**Huawei BM632w** (GPN supported, classification via GPN response) :
```
gpnStatus: supported
properties: 16
parameters: 15
compliance: compliant
```
## Identifiants du device de test
@@ -125,7 +145,47 @@ Dans parameters (BON): ['Device.ManagementServer.PeriodicInformInterval', 'Devic
| MAC (simulateur) | F8:B7:FF:00:11:22 |
| Data Model | tr-181 |
## API du simulateur
## Device Huawei (genieacs-sim)
| Champ | Valeur |
|-------|--------|
| ACS identifier | `f9747826c5545b0e49db7967` |
| Manufacturer | Huawei Technologies Co., Ltd. |
| OUI | 202BC1 |
| Product Class | BM632w |
| Serial Number | 000000 |
| Data Model | tr-098 (InternetGatewayDevice.*) |
| Container | `adam-acs-genieacs-sim-1` |
### Procédure de test Huawei
Le Huawei est simulé par le container `genieacs-sim` (défini dans `docker-compose.yml` avec le profile `test`). Il se crée automatiquement au démarrage du container.
**Supprimer le device :**
```bash
docker stop adam-acs-genieacs-sim-1 && docker rm adam-acs-genieacs-sim-1
curl -s -X DELETE http://localhost:8080/api/v1/devices/f9747826c5545b0e49db7967
```
**Recréer le device (relancer le container) :**
```bash
docker compose --profile test up -d genieacs-sim
```
Le container fait un Inform automatiquement au démarrage vers `http://app:8080/cwmp10`.
**Vérifier :**
```bash
sleep 15
curl -s "http://localhost:8080/api/v1/devices/f9747826c5545b0e49db7967" | python3 -c "
import sys,json
d=json.load(sys.stdin)
print('gpnStatus:', d.get('gpnStatus'))
print('dataModel:', d.get('dataModel'))
print('Properties:', len(d.get('properties',[])))
print('Parameters:', len(d.get('parameters',[])))
"
```
- Base URL : `http://localhost:9898/api/v1/device-simulator`
- Swagger : `http://localhost:9898/swagger/`
@@ -184,7 +184,6 @@ export class StateMachineManager extends EventEmitter {
// Start the machine
try {
machineInstance.status = MachineStatus.RUNNING
await stateMachine.start()
machineInstance.currentState = stateMachine.getCurrentStateName()
if (stateMachine.isCompleted()) {
@@ -0,0 +1,317 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock AdonisJS logger before imports
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}))
// Mock DeviceModel to avoid MongoDB dependency
vi.mock('../../../app/models/device.js', () => ({
default: {
createOrUpdate: vi.fn().mockResolvedValue({}),
findByDeviceId: vi.fn().mockResolvedValue(null),
},
}))
// Mock CommandModel
vi.mock('../../../app/models/command.js', () => ({
default: {
getCollection: vi.fn().mockReturnValue({
find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }),
countDocuments: vi.fn().mockResolvedValue(0),
}),
},
}))
import { MainStateMachine } from '../../../app/services/cwmp/main_state_machine.js'
import { Device } from '../../../app/domain/device/device.js'
import { DeviceId } from '../../../app/domain/device/device_id.js'
import { DeviceProperty, DeviceParameter } from '../../../app/domain/device/device_parameter.js'
import { CWMPTaskQueue } from '../../../app/services/cwmp/task_queue.js'
import { ParameterType } from '../../../app/domain/shared/types.js'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeTr181Device(): Device {
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
device.setDataModel('tr-181')
return device
}
function makeTr098Device(): Device {
const device = new Device(new DeviceId('Huawei Technologies Co., Ltd.', '202BC1', 'BM632w', '000000'))
device.setDataModel('tr-098')
return device
}
function makeUnknownDevice(): Device {
const device = new Device(new DeviceId('Unknown', '000000', 'Generic', 'SN001'))
device.setDataModel('unknown')
return device
}
// ---------------------------------------------------------------------------
// MainStateMachine — Initial state
// ---------------------------------------------------------------------------
describe('MainStateMachine — initial state', () => {
it('starts in idle state', () => {
const device = makeTr181Device()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
expect(sm.getCurrentStateName()).toBe('idle')
expect(sm.isCompleted()).toBe(false)
})
})
// ---------------------------------------------------------------------------
// MainStateMachine — TR-181 device flow (GPN on Device.ManagementServer.)
// ---------------------------------------------------------------------------
describe('MainStateMachine — TR-181 device', () => {
let device: Device
let taskQueue: CWMPTaskQueue
let sm: MainStateMachine
beforeEach(() => {
device = makeTr181Device()
taskQueue = new CWMPTaskQueue()
sm = new MainStateMachine(device, 'session-1', taskQueue)
})
it('transitions to discovering-parameters and sends GPN for Device.ManagementServer.', async () => {
const response = await sm.start()
expect(sm.getCurrentStateName()).toBe('discovering-parameters')
// Response should be a SOAP envelope with GetParameterNames
expect(response).toContain('GetParameterNames')
expect(response).toContain('Device.ManagementServer.')
})
it('advances past discovering-parameters after receiving a response', async () => {
await sm.start() // sends GPN, state = discovering-parameters
// Simulate any response (success or failure) — state machine advances
await sm.handleResponse(true, {})
// Should have moved past discovering-parameters (no more paths in queue)
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 = 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 = 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 = 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 configuration-evaluation', async () => {
const device = makeUnknownDevice()
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
await sm.start()
// Should skip discovering-parameters entirely
expect(sm.getCurrentStateName()).not.toBe('discovering-parameters')
expect(sm.getCurrentStateName()).not.toBe('idle')
})
})
// ---------------------------------------------------------------------------
// MainStateMachine — CompletedState reclassification
// ---------------------------------------------------------------------------
describe('MainStateMachine — CompletedState reclassification', () => {
it('reclassifies writable properties on completion when GPN unsupported', async () => {
const device = makeTr181Device()
device.setGpnStatus('unsupported')
// Add writable property that should be reclassified
device.setProperty(new DeviceProperty('Device.ManagementServer.PeriodicInformInterval', '60', ParameterType.UNSIGNED_INT))
// Add read-only property that should stay
device.setProperty(new DeviceProperty('Device.DeviceInfo.Manufacturer', 'Sagemcom', ParameterType.STRING))
const taskQueue = new CWMPTaskQueue()
const sm = new MainStateMachine(device, 'session-1', taskQueue)
// Start → since dataModel is tr-181, it will try GPN
await sm.start()
// Simulate GPN failure
await sm.handleResponse(true, {})
// The state machine should eventually complete
// Let it run through (no config match → completes)
let guard = 0
while (!sm.isCompleted() && guard++ < 10) {
await sm.handleResponse(true, {})
}
// After completion, PeriodicInformInterval should be a parameter
expect(device.getParameter('Device.ManagementServer.PeriodicInformInterval')).not.toBeNull()
expect(device.getProperty('Device.ManagementServer.PeriodicInformInterval')).toBeNull()
// Manufacturer should stay as property
expect(device.getProperty('Device.DeviceInfo.Manufacturer')).not.toBeNull()
expect(device.getParameter('Device.DeviceInfo.Manufacturer')).toBeNull()
})
})
// ---------------------------------------------------------------------------
// DeviceActionExecutor — reclassification after SPV (Livebox fallback scenario)
// ---------------------------------------------------------------------------
describe('DeviceActionExecutor — reclassification after updateFromValues', () => {
it('reclassifies properties added via SPV callback when GPN unsupported', 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 = 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 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()
})
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 = 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()
})
})
@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest'
import { reclassifyDeviceFromCatalog } from '../../../app/services/parameter_writability.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 { ParameterType } from '../../../app/domain/shared/types.js'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeDevice(gpnStatus: 'supported' | 'unsupported' | 'unknown' = 'unsupported'): Device {
const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001'))
device.setGpnStatus(gpnStatus)
device.setDataModel('tr-181')
return device
}
function addProperty(device: Device, name: string, value: any = null): void {
device.setProperty(new DeviceProperty(name, value, ParameterType.STRING))
}
// ---------------------------------------------------------------------------
// reclassifyDeviceFromCatalog
// ---------------------------------------------------------------------------
describe('reclassifyDeviceFromCatalog', () => {
it('moves writable properties to parameters (PeriodicInformInterval)', () => {
const device = makeDevice()
addProperty(device, 'Device.ManagementServer.PeriodicInformInterval', '30')
const count = reclassifyDeviceFromCatalog(device)
expect(count).toBe(1)
expect(device.getParameter('Device.ManagementServer.PeriodicInformInterval')).not.toBeNull()
expect(device.getProperty('Device.ManagementServer.PeriodicInformInterval')).toBeNull()
})
it('moves writable properties to parameters (ProvisioningCode)', () => {
const device = makeDevice()
addProperty(device, 'Device.DeviceInfo.ProvisioningCode', 'test-code')
const count = reclassifyDeviceFromCatalog(device)
expect(count).toBe(1)
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()
})
it('does NOT move read-only properties (Manufacturer)', () => {
const device = makeDevice()
addProperty(device, 'Device.DeviceInfo.Manufacturer', 'Sagemcom')
const count = reclassifyDeviceFromCatalog(device)
expect(count).toBe(0)
expect(device.getProperty('Device.DeviceInfo.Manufacturer')).not.toBeNull()
expect(device.getParameter('Device.DeviceInfo.Manufacturer')).toBeNull()
})
it('does NOT move properties not in catalog', () => {
const device = makeDevice()
addProperty(device, 'Device.Vendor.CustomParam.Unknown', 'value')
const count = reclassifyDeviceFromCatalog(device)
// Not in catalog → stays as property (default behavior for unknown paths)
expect(count).toBe(0)
expect(device.getProperty('Device.Vendor.CustomParam.Unknown')).not.toBeNull()
})
it('handles instance paths with numeric segments', () => {
const device = makeDevice()
// Device.IP.Interface.{i}.Enable is writable in catalog
addProperty(device, 'Device.IP.Interface.1.Enable', 'true')
const count = reclassifyDeviceFromCatalog(device)
expect(count).toBe(1)
expect(device.getParameter('Device.IP.Interface.1.Enable')).not.toBeNull()
expect(device.getProperty('Device.IP.Interface.1.Enable')).toBeNull()
})
it('reclassifies multiple properties in one call', () => {
const device = makeDevice()
addProperty(device, 'Device.ManagementServer.PeriodicInformInterval', '60')
addProperty(device, 'Device.DeviceInfo.ProvisioningCode', 'code')
addProperty(device, 'Device.DeviceInfo.Manufacturer', 'Sagemcom') // read-only, stays
addProperty(device, 'Device.DeviceInfo.SerialNumber', 'SN123') // read-only, stays
const count = reclassifyDeviceFromCatalog(device)
expect(count).toBe(2)
expect(device.getParameters()).toHaveLength(2)
expect(device.getProperties()).toHaveLength(2)
})
it('preserves value and type when moving property to parameter', () => {
const device = makeDevice()
device.setProperty(
new DeviceProperty('Device.ManagementServer.PeriodicInformInterval', '120', ParameterType.UNSIGNED_INT)
)
reclassifyDeviceFromCatalog(device)
const param = device.getParameter('Device.ManagementServer.PeriodicInformInterval')
expect(param).not.toBeNull()
expect(param!.value).toBe('120')
expect(param!.type).toBe(ParameterType.UNSIGNED_INT)
})
it('returns 0 when device has no properties', () => {
const device = makeDevice()
const count = reclassifyDeviceFromCatalog(device)
expect(count).toBe(0)
})
it('does not touch existing parameters', () => {
const device = makeDevice()
device.setParameter(new DeviceParameter('Device.ManagementServer.URL', 'http://acs', ParameterType.STRING))
addProperty(device, 'Device.DeviceInfo.Manufacturer', 'Sagemcom')
reclassifyDeviceFromCatalog(device)
expect(device.getParameter('Device.ManagementServer.URL')!.value).toBe('http://acs')
expect(device.getParameters()).toHaveLength(1)
})
it('works with InternetGatewayDevice paths (TR-098) — not in TR-181 catalog', () => {
const device = new Device(new DeviceId('Huawei', '202BC1', 'BM632w', '000000'))
device.setGpnStatus('unsupported')
device.setDataModel('tr-098')
addProperty(device, 'InternetGatewayDevice.DeviceInfo.ProvisioningCode', 'code')
addProperty(device, 'InternetGatewayDevice.ManagementServer.PeriodicInformInterval', '60')
const count = reclassifyDeviceFromCatalog(device)
// TR-098 paths are NOT in the TR-181 catalog → nothing reclassified
expect(count).toBe(0)
expect(device.getProperties()).toHaveLength(2)
expect(device.getParameters()).toHaveLength(0)
})
})
@@ -108,19 +108,19 @@ describe('Tr069TraceService', () => {
expect(fsPromisesMocks.writeFile).toHaveBeenCalledTimes(2)
expect(fsPromisesMocks.writeFile).toHaveBeenNthCalledWith(
1,
expect.stringContaining('config/tr069-trace.json.tmp'),
expect.stringContaining('tr069-trace.json.tmp'),
expect.stringContaining(baseEntry.deviceId),
'utf-8'
)
expect(fsPromisesMocks.writeFile).toHaveBeenNthCalledWith(
2,
expect.stringContaining('config/tr069-trace.json.tmp'),
expect.stringContaining('tr069-trace.json.tmp'),
JSON.stringify({ entries: [] }, null, 2),
'utf-8'
)
expect(fsMocks.renameSync).toHaveBeenCalledWith(
expect.stringContaining('config/tr069-trace.json.tmp'),
expect.stringContaining('config/tr069-trace.json')
expect.stringContaining('tr069-trace.json.tmp'),
expect.stringContaining('tr069-trace.json')
)
})