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:
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user