feat: compliance report + auto-versioning for configurations

- Add ComplianceStatus.PENDING and configVersion to DeviceComplianceInfo
- Add ComplianceReport + ComplianceDeviceResult types to ConfigurationEngine
- Expose evaluateSpecific() as public (used by compliance + dry-run)
- Add complianceReport() method: filter matching devices, evaluate each
- Fix matchesCriteria: skip parameterCriteria entries with null/empty name
- Fix getDeviceParameterValue: fallback to plain object arrays (DeviceRecord)
- Fix findDevice: support lookup by both identifier and deviceId.id
- ConfigurationService.update: version is now auto-managed (no user input)
  - behavioral changes (criteria/params/policy/priority/…) → patch bump
  - bumpType field allows 'patch' | 'minor' | 'major'
  - cosmetic changes (name/tags) → no bump
- Add sanitizeCriteria: filter null/empty parameterCriteria names silently
- Add isBehavioralChange helper
- Add DeviceModel.resetComplianceForConfiguration call on behavioral update
- Add ConfigurationModel.distinctTags() + controller endpoint GET /tags
- Add controller endpoint GET /configurations/:id/compliance
- Add DeviceModel.getDistinctValues for inventory fields
- Fix unit tests: mock DeviceModel, remove obsolete version-validation test
- Add 14 new unit tests (engine: 5, service: 9)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kevin L'HereecandCopilot committed 2026-06-01 11:32:17 +02:00
1 parent b8ff4b68a7
commit aff8e14a63
9 files changed
+598 -47

No files matched your search

@@ -5,6 +5,7 @@ import ConfigurationService, {
type UpdateConfigurationDto,
} from '#services/configuration_service'
import ConfigurationEngine from '#services/configuration_engine'
import ConfigurationModel from '#models/configuration'
import logger from '@adonisjs/core/services/logger'
/**
@@ -40,6 +41,7 @@ export default class ConfigurationsController {
page: Number(request.input('page', 1)),
perPage: Number(request.input('perPage', 20)),
tag: request.input('tag'),
name: request.input('name') || undefined,
enabled: request.input('enabled') !== undefined ? request.input('enabled') === 'true' : undefined,
productClass: request.input('productClass'),
}
@@ -52,6 +54,19 @@ export default class ConfigurationsController {
}
}
/**
* GET /api/v1/configurations/tags
* Return all distinct tags across configurations
*/
async listTags({ response }: HttpContext) {
try {
const tags = await ConfigurationModel.distinctTags()
return response.ok(tags)
} catch (error: any) {
return response.status(500).json({ message: 'Failed to list tags' })
}
}
/**
* GET /api/v1/configurations/:id
* Get single configuration with compliance statistics
@@ -121,7 +136,6 @@ export default class ConfigurationsController {
try {
const data = request.only([
'name',
'version',
'priority',
'policy',
'enabled',
@@ -131,6 +145,7 @@ export default class ConfigurationsController {
'desiredParameters',
'firmwareTarget',
'schedule',
'bumpType',
]) as UpdateConfigurationDto
// Check for overlaps if priority changed
@@ -254,6 +269,23 @@ export default class ConfigurationsController {
}
}
/**
* GET /api/v1/configurations/:id/compliance
* Compliance report: all devices matching criteria + their compliance status
*/
async compliance({ params, response }: HttpContext) {
try {
const report = await this.engine.complianceReport(params.id)
return response.ok(report)
} catch (error: any) {
logger.error({ error: error.message, id: params.id }, '[ConfigAPI] Compliance report failed')
if (error.message.includes('not found')) {
return response.status(404).json({ message: error.message })
}
return response.status(500).json({ message: 'Compliance report failed' })
}
}
/**
* POST /api/v1/configurations/:id/dry-run
* Perform dry-run evaluation for a device
+4 -1
View File
@@ -1,4 +1,5 @@
export enum ComplianceStatus {
PENDING = 'pending',
COMPLIANT = 'compliant',
IN_PROGRESS = 'in_progress',
FAILED = 'failed',
@@ -7,6 +8,8 @@ export enum ComplianceStatus {
export interface DeviceComplianceInfo {
status: ComplianceStatus
configurationIdentifier: string
/** Version of the config that was evaluated (used to detect stale results) */
configVersion?: string
lastEvaluatedAt: Date
lastError?: string
lastError?: string | null
}
+6
View File
@@ -31,6 +31,12 @@ export default class ConfigurationModel extends BaseModel {
return this.create(data) as Promise<Configuration>
}
static async distinctTags(): Promise<string[]> {
const collection = this.getCollection()
const tags = await collection.distinct('tags')
return (tags as string[]).filter(Boolean).sort()
}
static async deleteByIdentifier(identifier: string): Promise<boolean> {
return this.deleteOne({ identifier })
}
+53 -8
View File
@@ -93,25 +93,51 @@ export default class DeviceModel extends BaseModel {
oui: string[]
productClass: string[]
serialNumber: string[]
modelName: string[]
hardwareVersion: string[]
softwareVersion: string[]
parameterNames: string[]
}> {
const collection = this.getCollection()
const [manufacturer, oui, productClass, serialNumber] = await Promise.all([
collection.distinct('deviceId.manufacturer'),
collection.distinct('deviceId.oui'),
collection.distinct('deviceId.productClass'),
collection.distinct('deviceId.serialNumber'),
])
const distinctProperty = async (paths: string[]): Promise<string[]> => {
const rows = await collection
.aggregate([
{ $unwind: '$properties' },
{ $match: { 'properties.name': { $in: paths } } },
{ $group: { _id: '$properties.value' } },
])
.toArray()
return rows.map((r) => String(r._id)).filter(Boolean).sort()
}
const [manufacturer, oui, productClass, serialNumber, modelName, hardwareVersion, softwareVersion, parameterNames] =
await Promise.all([
collection.distinct('deviceId.manufacturer'),
collection.distinct('deviceId.oui'),
collection.distinct('deviceId.productClass'),
collection.distinct('deviceId.serialNumber'),
distinctProperty(['Device.DeviceInfo.ModelName', 'InternetGatewayDevice.DeviceInfo.ModelName']),
distinctProperty(['Device.DeviceInfo.HardwareVersion', 'InternetGatewayDevice.DeviceInfo.HardwareVersion']),
distinctProperty(['Device.DeviceInfo.SoftwareVersion', 'InternetGatewayDevice.DeviceInfo.SoftwareVersion']),
collection.distinct('properties.name'),
])
return {
manufacturer: (manufacturer as string[]).filter(Boolean).sort(),
oui: (oui as string[]).filter(Boolean).sort(),
productClass: (productClass as string[]).filter(Boolean).sort(),
serialNumber: (serialNumber as string[]).filter(Boolean).sort(),
modelName,
hardwareVersion,
softwareVersion,
parameterNames: (parameterNames as string[]).filter(Boolean).sort(),
}
}
static async countByComplianceStatus(
configurationIdentifier: string
): Promise<{ compliant: number; in_progress: number; failed: number }> {
): Promise<{ compliant: number; in_progress: number; failed: number; pending: number }> {
const collection = this.getCollection()
const results = await collection
.aggregate([
@@ -120,12 +146,31 @@ export default class DeviceModel extends BaseModel {
])
.toArray()
const counts = { compliant: 0, in_progress: 0, failed: 0 }
const counts = { compliant: 0, in_progress: 0, failed: 0, pending: 0 }
for (const r of results) {
if (r._id === 'compliant') counts.compliant = r.count
else if (r._id === 'in_progress') counts.in_progress = r.count
else if (r._id === 'failed') counts.failed = r.count
else if (r._id === 'pending') counts.pending = r.count
}
return counts
}
/**
* Reset compliance status to 'pending' for all devices linked to a configuration.
* Called after a behavioral configuration update to force re-evaluation on next Inform.
*/
static async resetComplianceForConfiguration(configurationIdentifier: string): Promise<void> {
const collection = this.getCollection()
await collection.updateMany(
{ 'compliance.configurationIdentifier': configurationIdentifier },
{
$set: {
'compliance.status': 'pending',
'compliance.lastError': null,
updatedAt: new Date(),
},
}
)
}
}
+83 -23
View File
@@ -8,6 +8,23 @@ import { isWithinSchedule } from '../utils/schedule_window.js'
import { matchGlob } from '../utils/glob_match.js'
import { computeContentHash, parseProvisioningCode } from '../utils/provisioning_code.js'
export interface ComplianceDeviceResult {
identifier: string
deviceId: any
compliant: boolean
parameterDiffs: ParameterDiff[]
firmwareMigrationPath: MigrationStep[]
}
export interface ComplianceReport {
configurationId: string
configurationName: string
totalDevices: number
compliantCount: number
nonCompliantCount: number
devices: ComplianceDeviceResult[]
}
/**
* Result of a single device configuration evaluation.
* Contains all the actions to be executed by the state machine.
@@ -161,7 +178,7 @@ export default class ConfigurationEngine {
}
const device = await DeviceModel.findOne({
'deviceId.id': deviceIdentifier,
$or: [{ identifier: deviceIdentifier }, { 'deviceId.id': deviceIdentifier }],
})
if (!device) {
throw new Error(`Device ${deviceIdentifier} not found`)
@@ -184,9 +201,9 @@ export default class ConfigurationEngine {
/**
* Evaluate a specific configuration against a device.
* Used by dry-run and potentially other flows.
* Used by dry-run, compliance check, and potentially other flows.
*/
private async evaluateSpecific(device: Device, config: Configuration): Promise<EngineResult> {
async evaluateSpecific(device: Device, config: Configuration): Promise<EngineResult> {
// Check policy "once"
if (!this.shouldApplyOnce(device, config)) {
return {
@@ -274,8 +291,12 @@ export default class ConfigurationEngine {
// parameterCriteria: all conditions must match a known device parameter value
if (Array.isArray(criteria.parameterCriteria) && criteria.parameterCriteria.length > 0) {
const allParams = [...(device as any).getParameters?.() ?? [], ...(device as any).getProperties?.() ?? []]
const hasGetters = typeof (device as any).getParameters === 'function'
const allParams = hasGetters
? [...(device as any).getParameters(), ...(device as any).getProperties()]
: [...((device as any).parameters ?? []), ...((device as any).properties ?? [])]
for (const pc of criteria.parameterCriteria as ParameterCriteria[]) {
if (!pc.name) continue
const known = allParams.find((p: any) => p.name === pc.name)
if (known === undefined || known.value === null || known.value === undefined) {
return false
@@ -363,25 +384,26 @@ export default class ConfigurationEngine {
* Returns null if not found.
*/
private getDeviceParameterValue(device: Device, paramName: string): string | number | boolean | null {
const param = (device as any).getParameter?.(paramName)
if (!param) {
// Try method-based access first (Device class instances)
if (typeof (device as any).getParameter === 'function') {
const param = (device as any).getParameter(paramName)
if (param) {
const value = param.value
if (value === undefined || value === null) return null
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value
return String(value)
}
return null
}
const value = param.value
if (value === undefined || value === null) {
return null
}
// Type coercion based on stored value type
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'number') {
return value
}
return String(value)
// Fallback: plain DeviceRecord objects (parameters + properties arrays)
const allEntries = [...((device as any).parameters ?? []), ...((device as any).properties ?? [])]
const entry = allEntries.find((p: any) => p.name === paramName)
if (!entry || entry.value === undefined || entry.value === null) return null
if (typeof entry.value === 'boolean') return entry.value
if (typeof entry.value === 'number') return entry.value
return String(entry.value)
}
/**
@@ -389,20 +411,58 @@ export default class ConfigurationEngine {
* Looks in device properties or parameters (e.g., "Device.DeviceInfo.SoftwareVersion")
*/
private getDeviceVersion(device: Device, productClass: string): string | null {
// Try common firmware version parameters
const versionParams = [
'Device.DeviceInfo.SoftwareVersion',
'InternetGatewayDevice.DeviceInfo.SoftwareVersion',
`Device.${productClass}.FirmwareVersion`,
]
for (const paramName of versionParams) {
const value = this.getDeviceParameterValue(device, paramName)
if (value && typeof value === 'string') {
return value
}
}
return null
}
/**
* Generate a compliance report for a configuration.
* Returns all devices matching the criteria with their compliance status.
*/
async complianceReport(configId: string): Promise<ComplianceReport> {
const configuration = await ConfigurationModel.findOne({ identifier: configId })
if (!configuration) {
throw new Error(`Configuration ${configId} not found`)
}
// Load all devices
const collection = DeviceModel.getCollection()
const allDevices = (await collection.find({}).toArray()) as unknown as Device[]
// Filter by criteria
const matchingDevices = allDevices.filter((d) => this.matchesCriteria(d, configuration))
// Evaluate each matching device
const devices: ComplianceDeviceResult[] = await Promise.all(
matchingDevices.map(async (device) => {
const evaluation = await this.evaluateSpecific(device, configuration)
return {
identifier: (device as any).identifier ?? (device as any).deviceId?.serialNumber ?? '?',
deviceId: (device as any).deviceId,
compliant: evaluation.isAlreadyCompliant,
parameterDiffs: evaluation.parameterDiffs,
firmwareMigrationPath: evaluation.firmwareMigrationPath,
}
})
)
return {
configurationId: configId,
configurationName: configuration.name,
totalDevices: devices.length,
compliantCount: devices.filter((d) => d.compliant).length,
nonCompliantCount: devices.filter((d) => !d.compliant).length,
devices,
}
}
}
+63 -5
View File
@@ -1,5 +1,6 @@
import ConfigurationModel from '#models/configuration'
import ConfigurationVersionModel from '#models/configuration_version'
import DeviceModel from '#models/device'
import {
type Configuration,
type ConfigurationCriteria,
@@ -29,6 +30,7 @@ export interface ConfigurationFilters {
page?: number
perPage?: number
tag?: string
name?: string
enabled?: boolean
productClass?: string
}
@@ -53,7 +55,9 @@ export interface CreateConfigurationDto {
}
}
export type UpdateConfigurationDto = Partial<Omit<CreateConfigurationDto, 'identifier'>>
export type UpdateConfigurationDto = Partial<Omit<CreateConfigurationDto, 'identifier'>> & {
bumpType?: 'patch' | 'minor' | 'major'
}
export interface OverlapWarning {
type: 'overlap_different_priority'
@@ -83,6 +87,7 @@ export default class ConfigurationService {
this.validateVersion(data.version)
this.validatePriority(data.priority)
this.validatePolicy(data.policy)
if (data.criteria) data.criteria = this.sanitizeCriteria(data.criteria)
this.validateCriteria(data.criteria)
this.validateParameterCriteriaDeadlock(data.criteria, data.desiredParameters)
this.validateInventoryCoverage(data.desiredParameters, data.inventory)
@@ -126,6 +131,10 @@ export default class ConfigurationService {
mongoFilter.tags = filters.tag
}
if (filters.name) {
mongoFilter.name = { $regex: filters.name, $options: 'i' }
}
if (filters.enabled !== undefined) {
mongoFilter.enabled = filters.enabled
}
@@ -144,9 +153,38 @@ export default class ConfigurationService {
return ConfigurationModel.findByIdentifier(identifier)
}
/**
* Increment the appropriate semver segment based on bump type.
* patch: 1.0.0 → 1.0.1 | minor: 1.0.0 → 1.1.0 | major: 1.0.0 → 2.0.0
*/
private bumpVersion(version: string, type: 'patch' | 'minor' | 'major' = 'patch'): string {
const [maj, min, pat] = version.split('.').map(Number)
if (type === 'major') return `${maj + 1}.0.0`
if (type === 'minor') return `${maj}.${min + 1}.0`
return `${maj}.${min}.${(pat ?? 0) + 1}`
}
/**
* Returns true if the update payload touches behavioral fields that affect
* device provisioning — as opposed to cosmetic fields like name or tags.
*/
private isBehavioralChange(data: UpdateConfigurationDto): boolean {
return (
data.criteria !== undefined ||
data.desiredParameters !== undefined ||
data.inventory !== undefined ||
data.policy !== undefined ||
data.priority !== undefined ||
data.firmwareTarget !== undefined ||
data.schedule !== undefined ||
data.enabled !== undefined
)
}
/**
* Update an existing Configuration after validation.
* Creates a version snapshot of the current state before applying changes.
* Auto-increments the patch version on behavioral changes and resets device compliance.
*/
async update(identifier: string, data: UpdateConfigurationDto): Promise<Configuration & { warning?: OverlapWarning }> {
const existing = await ConfigurationModel.findByIdentifier(identifier)
@@ -154,9 +192,6 @@ export default class ConfigurationService {
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
}
if (data.version !== undefined) {
this.validateVersion(data.version)
}
if (data.priority !== undefined) {
this.validatePriority(data.priority)
}
@@ -164,6 +199,7 @@ export default class ConfigurationService {
this.validatePolicy(data.policy)
}
if (data.criteria !== undefined) {
data.criteria = this.sanitizeCriteria(data.criteria)
this.validateCriteria(data.criteria)
}
@@ -182,6 +218,8 @@ export default class ConfigurationService {
overlapResult = await this.detectOverlap(finalCriteria, finalPriority, identifier)
}
const behavioralChange = this.isBehavioralChange(data)
// Create a version snapshot BEFORE applying changes
const latestVersionNumber = await ConfigurationVersionModel.getLatestVersionNumber(identifier)
const versionSnapshot: Omit<ConfigurationVersion, '_id'> = {
@@ -193,9 +231,12 @@ export default class ConfigurationService {
await ConfigurationVersionModel.create(versionSnapshot)
// Build update payload (only provided fields)
// Version is always auto-managed: bump on behavioral changes using the requested type
const updatePayload: Record<string, any> = {}
if (behavioralChange) {
updatePayload.version = this.bumpVersion(existing.version, data.bumpType ?? 'patch')
}
if (data.name !== undefined) updatePayload.name = data.name
if (data.version !== undefined) updatePayload.version = data.version
if (data.priority !== undefined) updatePayload.priority = data.priority
if (data.policy !== undefined) updatePayload.policy = data.policy
if (data.enabled !== undefined) updatePayload.enabled = data.enabled
@@ -208,6 +249,11 @@ export default class ConfigurationService {
await ConfigurationModel.updateOne({ identifier }, updatePayload)
// Reset compliance for all devices linked to this config so they re-evaluate
if (behavioralChange) {
await DeviceModel.resetComplianceForConfiguration(identifier)
}
const updated = await ConfigurationModel.findByIdentifier(identifier)
if (overlapResult) {
@@ -488,6 +534,18 @@ export default class ConfigurationService {
}
}
/** Remove parameterCriteria entries with null/empty name — avoids silent matching failures. */
private sanitizeCriteria(criteria: ConfigurationCriteria): ConfigurationCriteria {
if (!criteria.parameterCriteria?.length) return criteria
const filtered = criteria.parameterCriteria.filter(
(pc) => pc.name != null && String(pc.name).trim() !== ''
)
return {
...criteria,
parameterCriteria: filtered.length > 0 ? filtered : undefined,
}
}
/**
* Warn if a parameterCriteria path is also in desiredParameters — this creates a risk of
* the config never matching (deadlock) if the device doesn't already have that parameter value.
+2
View File
@@ -34,6 +34,7 @@ router.group(() => {
// Unified Configuration API (v1)
router.get('/configurations', [ConfigurationsV1Controller, 'index'])
router.get('/configurations/tags', [ConfigurationsV1Controller, 'listTags'])
router.post('/configurations', [ConfigurationsV1Controller, 'store'])
router.get('/configurations/:id', [ConfigurationsV1Controller, 'show'])
router.put('/configurations/:id', [ConfigurationsV1Controller, 'update'])
@@ -42,6 +43,7 @@ router.group(() => {
router.patch('/configurations/:id/tags', [ConfigurationsV1Controller, 'manageTags'])
router.get('/configurations/:id/history', [ConfigurationsV1Controller, 'history'])
router.post('/configurations/:id/dry-run', [ConfigurationsV1Controller, 'dryRun'])
router.get('/configurations/:id/compliance', [ConfigurationsV1Controller, 'compliance'])
// Firmware Catalog API (v1)
router.get('/firmware-catalog', [FirmwareCatalogController, 'index'])
@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ConfigurationEngine from '../../../app/services/configuration_engine.js'
vi.mock('#models/configuration', () => ({
default: { findOne: vi.fn(), findAllEnabled: vi.fn() },
}))
vi.mock('#models/device', () => ({
default: { getCollection: vi.fn(), findOne: vi.fn() },
}))
import ConfigurationModel from '#models/configuration'
import DeviceModel from '#models/device'
describe('ConfigurationEngine', () => {
let engine: ConfigurationEngine
beforeEach(() => {
engine = new ConfigurationEngine()
vi.clearAllMocks()
})
describe('complianceReport', () => {
it('throws when configuration is not found', async () => {
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(null as any)
await expect(engine.complianceReport('missing-config')).rejects.toThrow(
'Configuration missing-config not found'
)
})
it('returns an empty report when no devices match', async () => {
vi.mocked(ConfigurationModel.findOne).mockResolvedValue({
identifier: 'config-1',
name: 'Config 1',
criteria: {},
inventory: [],
desiredParameters: [],
policy: 'maintain',
} as any)
vi.mocked(DeviceModel.getCollection).mockReturnValue({
find: () => ({ toArray: async () => [] }),
} as any)
const report = await engine.complianceReport('config-1')
expect(report).toMatchObject({
configurationId: 'config-1',
configurationName: 'Config 1',
totalDevices: 0,
compliantCount: 0,
nonCompliantCount: 0,
devices: [],
})
})
it('counts compliant and non-compliant devices', async () => {
const config = {
identifier: 'config-1',
name: 'Config 1',
criteria: {},
inventory: [],
desiredParameters: [],
policy: 'maintain',
}
const devices = [
{ identifier: 'device-1', deviceId: { serialNumber: 'SN1' } },
{ identifier: 'device-2', deviceId: { serialNumber: 'SN2' } },
]
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(config as any)
vi.mocked(DeviceModel.getCollection).mockReturnValue({
find: () => ({ toArray: async () => devices }),
} as any)
vi.spyOn(engine, 'evaluateSpecific')
.mockResolvedValueOnce({
configuration: config as any,
inventoryPaths: [],
parameterDiffs: [],
firmwareMigrationPath: [],
isAlreadyCompliant: true,
skipFirmware: false,
})
.mockResolvedValueOnce({
configuration: config as any,
inventoryPaths: [],
parameterDiffs: [
{
name: 'Device.ManagementServer.URL',
currentValue: 'http://old',
targetValue: 'http://acs',
type: 'string',
},
],
firmwareMigrationPath: [],
isAlreadyCompliant: false,
skipFirmware: false,
})
const report = await engine.complianceReport('config-1')
expect(report.totalDevices).toBe(2)
expect(report.compliantCount).toBe(1)
expect(report.nonCompliantCount).toBe(1)
expect(report.devices.map((device) => device.compliant)).toEqual([true, false])
})
it('skips null parameterCriteria names when matching devices', async () => {
const config = {
identifier: 'config-1',
name: 'Config 1',
criteria: { parameterCriteria: [{ name: null as any, value: 'x' }] },
inventory: [],
desiredParameters: [],
policy: 'maintain',
}
const device = { identifier: 'device-1', deviceId: { serialNumber: 'SN1' } }
vi.mocked(ConfigurationModel.findOne).mockResolvedValue(config as any)
vi.mocked(DeviceModel.getCollection).mockReturnValue({
find: () => ({ toArray: async () => [device] }),
} as any)
vi.spyOn(engine, 'evaluateSpecific').mockResolvedValue({
configuration: config as any,
inventoryPaths: [],
parameterDiffs: [],
firmwareMigrationPath: [],
isAlreadyCompliant: true,
skipFirmware: false,
})
const report = await engine.complianceReport('config-1')
expect(report.totalDevices).toBe(1)
expect(report.compliantCount).toBe(1)
expect(engine.evaluateSpecific).toHaveBeenCalledWith(device, config)
})
})
describe('evaluateSpecific', () => {
it('falls back to plain object parameters when getParameter is unavailable', async () => {
const device = {
identifier: 'device-1',
deviceId: { serialNumber: 'SN1' },
parameters: [
{
name: 'Device.ManagementServer.URL',
value: 'http://acs',
},
],
}
const config = {
identifier: 'config-1',
name: 'Config 1',
version: '1.0.0',
priority: 100,
policy: 'maintain',
enabled: true,
tags: [],
criteria: {},
inventory: [],
desiredParameters: [
{
name: 'Device.ManagementServer.URL',
value: 'http://acs',
type: 'string',
},
],
}
const result = await engine.evaluateSpecific(device as any, config as any)
expect(result.isAlreadyCompliant).toBe(true)
expect(result.parameterDiffs).toEqual([])
})
})
})
@@ -30,8 +30,17 @@ vi.mock('#models/configuration_version', () => {
}
})
vi.mock('#models/device', () => {
return {
default: {
resetComplianceForConfiguration: vi.fn(),
},
}
})
import ConfigurationModel from '#models/configuration'
import ConfigurationVersionModel from '#models/configuration_version'
import DeviceModel from '#models/device'
function buildValidDto(overrides: Partial<CreateConfigurationDto> = {}): CreateConfigurationDto {
return {
@@ -64,6 +73,7 @@ describe('ConfigurationService', () => {
// Default mock returns for version model methods used in update
vi.mocked(ConfigurationVersionModel.getLatestVersionNumber).mockResolvedValue(0)
vi.mocked(ConfigurationVersionModel.create).mockResolvedValue({} as any)
vi.mocked(DeviceModel.resetComplianceForConfiguration).mockResolvedValue(undefined)
// Default: no existing configurations (no overlap)
vi.mocked(ConfigurationModel.findAllEnabled).mockResolvedValue([])
})
@@ -451,15 +461,6 @@ describe('ConfigurationService', () => {
expect(result.name).toBe('Updated Name')
})
it('validates version on update', async () => {
const existing = buildValidDto()
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(existing as any)
await expect(service.update('BM632w-standard', { version: 'invalid' })).rejects.toThrow(
ValidationError
)
})
it('validates priority on update', async () => {
const existing = buildValidDto()
vi.mocked(ConfigurationModel.findByIdentifier).mockResolvedValue(existing as any)
@@ -500,6 +501,175 @@ describe('ConfigurationService', () => {
})
})
describe('update — version auto-management', () => {
it('bumps patch version on behavioral change by default', async () => {
const existing = buildValidDto({ version: '1.0.0' })
const updatedCriteria = { manufacturer: 'Sagemcom', productClass: 'HG*' }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, version: '1.0.1', criteria: updatedCriteria } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.update('BM632w-standard', { criteria: updatedCriteria })
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
expect.objectContaining({ version: '1.0.1', criteria: updatedCriteria })
)
expect(result.version).toBe('1.0.1')
})
it('bumps minor version when bumpType is minor', async () => {
const existing = buildValidDto({ version: '1.0.0' })
const updatedCriteria = { manufacturer: 'Sagemcom', productClass: 'HG*' }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, version: '1.1.0', criteria: updatedCriteria } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.update('BM632w-standard', {
criteria: updatedCriteria,
bumpType: 'minor',
})
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
expect.objectContaining({ version: '1.1.0', criteria: updatedCriteria })
)
expect(result.version).toBe('1.1.0')
})
it('bumps major version when bumpType is major', async () => {
const existing = buildValidDto({ version: '1.0.0' })
const updatedCriteria = { manufacturer: 'Sagemcom', productClass: 'HG*' }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, version: '2.0.0', criteria: updatedCriteria } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.update('BM632w-standard', {
criteria: updatedCriteria,
bumpType: 'major',
})
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
expect.objectContaining({ version: '2.0.0', criteria: updatedCriteria })
)
expect(result.version).toBe('2.0.0')
})
it('does not bump version on cosmetic change only', async () => {
const existing = buildValidDto({ version: '1.0.0' })
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, name: 'Updated Name' } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
const result = await service.update('BM632w-standard', { name: 'Updated Name' })
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
{ name: 'Updated Name' }
)
expect(result.version).toBe('1.0.0')
})
it('calls resetComplianceForConfiguration on behavioral change', async () => {
const existing = buildValidDto({ version: '1.0.0' })
const updatedCriteria = { manufacturer: 'Sagemcom', productClass: 'HG*' }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, version: '1.0.1', criteria: updatedCriteria } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
await service.update('BM632w-standard', { criteria: updatedCriteria })
expect(DeviceModel.resetComplianceForConfiguration).toHaveBeenCalledWith(
'BM632w-standard'
)
})
it('does not call resetComplianceForConfiguration on cosmetic change only', async () => {
const existing = buildValidDto({ version: '1.0.0' })
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, name: 'Updated Name' } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
await service.update('BM632w-standard', { name: 'Updated Name' })
expect(DeviceModel.resetComplianceForConfiguration).not.toHaveBeenCalled()
})
})
describe('sanitizeCriteria', () => {
it('filters null names from parameterCriteria on update', async () => {
const existing = buildValidDto({ version: '1.0.0' })
const sanitizedCriteria = { parameterCriteria: undefined }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, version: '1.0.1', criteria: sanitizedCriteria } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
await expect(
service.update('BM632w-standard', {
criteria: { parameterCriteria: [{ name: null as any, value: 'x' }] },
})
).resolves.not.toThrow()
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
expect.objectContaining({ version: '1.0.1', criteria: sanitizedCriteria })
)
})
it('filters empty string names from parameterCriteria on update', async () => {
const existing = buildValidDto({ version: '1.0.0' })
const sanitizedCriteria = { parameterCriteria: undefined }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, version: '1.0.1', criteria: sanitizedCriteria } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
await expect(
service.update('BM632w-standard', {
criteria: { parameterCriteria: [{ name: '' as any, value: 'x' }] },
})
).resolves.not.toThrow()
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
expect.objectContaining({ version: '1.0.1', criteria: sanitizedCriteria })
)
})
it('keeps valid parameterCriteria entries after filtering invalid ones', async () => {
const existing = buildValidDto({ version: '1.0.0' })
const validEntry = {
name: 'InternetGatewayDevice.DeviceInfo.SerialNumber',
value: 'SN123',
}
const sanitizedCriteria = { parameterCriteria: [validEntry] }
vi.mocked(ConfigurationModel.findByIdentifier)
.mockResolvedValueOnce(existing as any)
.mockResolvedValueOnce({ ...existing, version: '1.0.1', criteria: sanitizedCriteria } as any)
vi.mocked(ConfigurationModel.updateOne).mockResolvedValue({} as any)
await service.update('BM632w-standard', {
criteria: {
parameterCriteria: [{ name: '' as any, value: 'x' }, validEntry],
},
})
expect(ConfigurationModel.updateOne).toHaveBeenCalledWith(
{ identifier: 'BM632w-standard' },
expect.objectContaining({ version: '1.0.1', criteria: sanitizedCriteria })
)
})
})
describe('delete', () => {
it('deletes configuration and its versions', async () => {
vi.mocked(ConfigurationModel.deleteByIdentifier).mockResolvedValue(true)