Files
adam-acs/tests/property/configuration_engine.property.spec.ts
T
Kevin L'HereecandCopilot d1c65dbf1e chore: initial commit — AdonisJS v7 TR-069 ACS
- Docker infrastructure (Dockerfile, docker-compose.yml, genieacs-sim)
- AdonisJS v7 app with Inertia.js + Vue 3
- MongoDB integration
- CWMP/TR-069 state machine engine
- Configuration engine with compliance tracking
- Branding: ADAM ACS (favicon, navbar, locales)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 16:46:37 +02:00

481 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import fc from 'fast-check'
import ConfigurationEngine from '../../app/services/configuration_engine.js'
import type { Configuration, ConfigurationCriteria } from '../../app/domain/configuration/configuration.js'
import { PolicyType } from '../../app/domain/configuration/configuration.js'
import type { Device } from '../../app/domain/device/device.js'
import type { DeviceId } from '../../app/domain/device/device_id.js'
import { buildProvisioningCode } from '../../app/utils/provisioning_code.js'
// Mock the models and services
vi.mock('#models/configuration', () => {
return {
default: {
find: vi.fn(),
findOne: vi.fn(),
},
}
})
vi.mock('#models/device', () => {
return {
default: {
findOne: vi.fn(),
},
}
})
vi.mock('../../app/services/firmware_catalog_service.js', () => {
return {
default: class FirmwareCatalogService {
async resolveUpgradePath() {
return []
}
},
}
})
import ConfigurationModel from '#models/configuration'
import DeviceModel from '#models/device'
/**
* Property 6: Highest priority Configuration wins
* Validates: Requirements 3.2
*
* Property: When multiple configurations match a device, the one with highest
* priority is selected. If no configurations match, result is compliant.
*/
describe('Property 6: Highest priority Configuration wins', () => {
let engine: ConfigurationEngine
beforeEach(() => {
engine = new ConfigurationEngine()
vi.clearAllMocks()
})
// Arbitrary to generate positive priority integers
const priority = fc.integer({ min: 1, max: 1000 })
// Arbitrary to generate a valid device
function deviceArb(): fc.Arbitrary<Device> {
return fc.record({
deviceId: fc.record({
id: fc.uuid(),
manufacturer: fc.string(),
oui: fc.string(),
productClass: fc.string(),
serialNumber: fc.string(),
}) as unknown as fc.Arbitrary<DeviceId>,
createdAt: fc.date(),
}) as unknown as fc.Arbitrary<Device>
}
// Arbitrary to generate a valid configuration
function configurationArb(overrides?: Partial<Configuration>): fc.Arbitrary<Configuration> {
const baseArb = fc.record({
identifier: fc.string().filter((s) => s.length > 0),
name: fc.string(),
version: fc.stringMatching(/\d+\.\d+\.\d+/),
priority: priority,
policy: fc.constantFrom(PolicyType.ONCE, PolicyType.MAINTAIN),
enabled: fc.constant(true),
tags: fc.array(fc.string()),
criteria: fc.record({
manufacturer: fc.option(fc.string(), { nil: undefined }),
oui: fc.option(fc.string(), { nil: undefined }),
productClass: fc.option(fc.string(), { nil: undefined }),
serialNumber: fc.option(fc.string(), { nil: undefined }),
}) as fc.Arbitrary<ConfigurationCriteria>,
inventory: fc.array(
fc.record({
path: fc.string(),
})
) as fc.Arbitrary<any>,
desiredParameters: fc.array(
fc.record({
name: fc.string(),
value: fc.oneof(fc.string(), fc.integer(), fc.boolean()),
type: fc.constantFrom('string', 'int', 'boolean'),
})
) as fc.Arbitrary<any>,
firmwareTarget: fc.option(
fc.record({
productClass: fc.string(),
targetVersion: fc.stringMatching(/\d+\.\d+\.\d+/),
}),
{ nil: undefined }
) as fc.Arbitrary<any>,
schedule: fc.constant(undefined) as fc.Arbitrary<any>,
createdAt: fc.date() as fc.Arbitrary<any>,
updatedAt: fc.date() as fc.Arbitrary<any>,
})
if (overrides) {
return baseArb.map((val) => ({
...val,
...overrides,
})) as fc.Arbitrary<Configuration>
}
return baseArb as fc.Arbitrary<Configuration>
}
it('selects configuration with highest priority when multiple match', async () => {
await fc.assert(
fc.asyncProperty(deviceArb(), async (device) => {
// Generate configurations with arbitrary priorities
const numConfigs = fc.sample(fc.integer({ min: 2, max: 5 }), 1)[0]
const configsArray = fc.sample(configurationArb(), numConfigs)
// Ensure all have matching criteria (empty)
const configurations = configsArray.map((c) => ({
...c,
criteria: {} as ConfigurationCriteria,
}))
vi.mocked(ConfigurationModel.find).mockResolvedValue(configurations as any)
vi.mocked(DeviceModel.findOne).mockResolvedValue(device as any)
const result = await engine.evaluate(device)
// Find expected highest priority config
const highest = configurations.reduce((max, c) =>
c.priority > max.priority ? c : max
)
expect(result.configuration?.priority).toBe(highest.priority)
expect(result.configuration?.identifier).toBe(highest.identifier)
})
)
})
it('returns compliant result when no configurations match', async () => {
await fc.assert(
fc.asyncProperty(deviceArb(), async (device) => {
vi.mocked(ConfigurationModel.find).mockResolvedValue([])
const result = await engine.evaluate(device)
expect(result.configuration).toBeNull()
expect(result.isAlreadyCompliant).toBe(true)
expect(result.parameterDiffs).toEqual([])
expect(result.firmwareMigrationPath).toEqual([])
})
)
})
})
/**
* Property 8: Diff engine computes only differing parameters
* Validates: Requirements 5.1, 5.2, 5.3, 6.5
*
* Property: The diff computation returns only parameters where currentValue !== targetValue.
*/
describe('Property 8: Diff engine computes only differing parameters', () => {
let engine: ConfigurationEngine
beforeEach(() => {
engine = new ConfigurationEngine()
vi.clearAllMocks()
})
it('returns empty diffs when all desired parameters match current values', async () => {
await fc.assert(
fc.asyncProperty(
fc.array(
fc.record({
name: fc.string().filter((s) => s.length > 0),
value: fc.oneof(fc.string(), fc.integer(), fc.boolean()),
type: fc.constantFrom('string', 'int', 'boolean'),
}),
{ minLength: 1, maxLength: 10 }
).map((params) => {
// Deduplicate by name to avoid conflicting values for the same parameter
const seen = new Set<string>()
return params.filter((p) => {
if (seen.has(p.name)) return false
seen.add(p.name)
return true
})
}).filter((params) => params.length > 0),
async (desiredParams) => {
const device = {
deviceId: { id: 'test', manufacturer: 'Test' },
createdAt: new Date(),
getParameter: (name: string) => ({
value: desiredParams.find((p) => p.name === name)?.value,
}),
} as any
const config = {
identifier: 'test',
priority: 1,
enabled: true,
policy: PolicyType.MAINTAIN,
criteria: {},
inventory: [],
desiredParameters: desiredParams,
createdAt: new Date(),
updatedAt: new Date(),
} as any
vi.mocked(ConfigurationModel.find).mockResolvedValue([config])
vi.mocked(DeviceModel.findOne).mockResolvedValue(device)
const result = await engine.evaluate(device)
expect(result.parameterDiffs).toEqual([])
}
)
)
})
it('returns diffs only for parameters that differ', async () => {
const desiredParams = [
{ name: 'param1', value: 'value1', type: 'string' },
{ name: 'param2', value: 'value2', type: 'string' },
]
const device = {
deviceId: { id: 'test', manufacturer: 'Test' },
createdAt: new Date(),
getParameter: (name: string) => {
if (name === 'param1') return { value: 'value1' } // Matches
if (name === 'param2') return { value: 'different' } // Differs
return { value: undefined }
},
} as any
const config = {
identifier: 'test',
priority: 1,
enabled: true,
policy: PolicyType.MAINTAIN,
criteria: {},
inventory: [],
desiredParameters: desiredParams,
createdAt: new Date(),
updatedAt: new Date(),
} as any
vi.mocked(ConfigurationModel.find).mockResolvedValue([config])
const engine = new ConfigurationEngine()
const result = await engine.evaluate(device)
expect(result.parameterDiffs).toHaveLength(1)
expect(result.parameterDiffs[0].name).toBe('param2')
expect(result.parameterDiffs[0].currentValue).toBe('different')
expect(result.parameterDiffs[0].targetValue).toBe('value2')
})
})
/**
* Property 10: Policy "once" applies based on content hash comparison
* Validates: Requirements 6.1, 6.3
*
* Property: A configuration with policy "once" applies only if the device
* doesn't have a matching provisioning code with the same content hash.
*/
describe('Property 10: Policy "once" applies based on content hash comparison', () => {
let engine: ConfigurationEngine
beforeEach(() => {
engine = new ConfigurationEngine()
vi.clearAllMocks()
})
it('applies "once" policy if device has no provisioning code', async () => {
const config = {
identifier: 'config1',
priority: 1,
enabled: true,
policy: PolicyType.ONCE,
criteria: {},
inventory: [],
desiredParameters: [{ name: 'param1', value: 'value1', type: 'string' }],
createdAt: new Date(),
updatedAt: new Date(),
} as any
const device = {
deviceId: { id: 'test', manufacturer: 'Test' },
createdAt: new Date(),
getParameter: (name: string) => ({
value: name === 'param1' ? 'different' : undefined,
}),
// No provisioningCode property
} as any
vi.mocked(ConfigurationModel.find).mockResolvedValue([config])
const result = await engine.evaluate(device)
expect(result.parameterDiffs).toHaveLength(1)
})
it('skips "once" policy if provisioning code matches content hash', async () => {
const config = {
identifier: 'config1',
priority: 1,
enabled: true,
policy: PolicyType.ONCE,
criteria: {},
inventory: [],
desiredParameters: [{ name: 'param1', value: 'value1', type: 'string' }],
createdAt: new Date(),
updatedAt: new Date(),
} as any
const provCode = buildProvisioningCode(config)
const device = {
deviceId: { id: 'test', manufacturer: 'Test' },
createdAt: new Date(),
getParameter: (name: string) => ({
value: name === 'param1' ? 'value1' : undefined,
}),
provisioningCode: provCode,
} as any
vi.mocked(ConfigurationModel.find).mockResolvedValue([config])
const result = await engine.evaluate(device)
expect(result.isAlreadyCompliant).toBe(true)
expect(result.parameterDiffs).toEqual([])
})
it('reapplies "once" policy if content hash changes', async () => {
const configV1 = {
identifier: 'config1',
priority: 1,
enabled: true,
policy: PolicyType.ONCE,
criteria: {},
inventory: [],
desiredParameters: [{ name: 'param1', value: 'value1', type: 'string' }],
createdAt: new Date(),
updatedAt: new Date(),
} as any
const provCodeV1 = buildProvisioningCode(configV1)
// New config version with different content
const configV2 = {
...configV1,
desiredParameters: [{ name: 'param1', value: 'value2', type: 'string' }],
} as any
const device = {
deviceId: { id: 'test', manufacturer: 'Test' },
createdAt: new Date(),
getParameter: (name: string) => ({
value: name === 'param1' ? 'value1' : undefined,
}),
provisioningCode: provCodeV1, // Has old provisioning code
} as any
vi.mocked(ConfigurationModel.find).mockResolvedValue([configV2])
const result = await engine.evaluate(device)
// Should reapply because content hash changed
expect(result.parameterDiffs).toHaveLength(1)
expect(result.parameterDiffs[0].targetValue).toBe('value2')
})
})
/**
* Property 16: Dry-run is side-effect-free
* Validates: Requirements 16.1, 16.2
*
* Property: Calling dryRun() should not modify the device or database state.
*/
describe('Property 16: Dry-run is side-effect-free', () => {
let engine: ConfigurationEngine
beforeEach(() => {
engine = new ConfigurationEngine()
vi.clearAllMocks()
})
it('dryRun returns EngineResult without modifying device', async () => {
const config = {
identifier: 'config1',
priority: 1,
enabled: true,
policy: PolicyType.MAINTAIN,
criteria: {},
inventory: [],
desiredParameters: [{ name: 'param1', value: 'value1', type: 'string' }],
createdAt: new Date(),
updatedAt: new Date(),
} as any
const device = {
deviceId: { id: 'test', manufacturer: 'Test' },
createdAt: new Date(),
getParameter: (name: string) => ({
value: name === 'param1' ? 'different' : undefined,
}),
} as any
// Make a deep copy to check it hasn't changed
const deviceSnapshot = JSON.parse(JSON.stringify(device))
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(config)
vi.mocked(DeviceModel.findOne).mockResolvedValue(device)
const result = await engine.dryRun('config1', 'test')
// Verify device snapshot hasn't changed
expect(JSON.stringify(device)).toBe(JSON.stringify(deviceSnapshot))
// Verify result is valid
expect(result).toHaveProperty('configurationId', 'config1')
expect(result).toHaveProperty('deviceIdentifier', 'test')
expect(result.evaluation).toHaveProperty('parameterDiffs')
})
it('dryRun does not persist any changes', async () => {
const config = {
identifier: 'config1',
priority: 1,
enabled: true,
policy: PolicyType.ONCE,
criteria: {},
inventory: [],
desiredParameters: [{ name: 'param1', value: 'value1', type: 'string' }],
createdAt: new Date(),
updatedAt: new Date(),
} as any
const device = {
deviceId: { id: 'test', manufacturer: 'Test' },
createdAt: new Date(),
getParameter: (name: string) => ({
value: name === 'param1' ? 'different' : undefined,
}),
} as any
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(config)
vi.mocked(DeviceModel.findOne).mockResolvedValue(device)
const result = await engine.dryRun('config1', 'test')
// Verify result is a DryRunResult with expected properties
expect(result).toBeDefined()
expect(result.configurationId).toBe('config1')
expect(result.deviceIdentifier).toBe('test')
// Verify no save/update calls were made (mocks should not have been called)
// Since we're mocking, any DB mutations would show up in mock call counts
expect(vi.mocked(ConfigurationModel.findOne)).toHaveBeenCalledTimes(1) // Only read call
expect(vi.mocked(DeviceModel.findOne)).toHaveBeenCalledTimes(1) // Only read call
// No mutation methods should have been called
// (we didn't mock save/update, so they shouldn't exist on our mocked objects)
})
})