- 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>
481 lines
15 KiB
TypeScript
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)
|
|
})
|
|
})
|