From aff8e14a63767994582aa32b1b82a344ba69d3f1 Mon Sep 17 00:00:00 2001 From: Kevin L'Hereec Date: Mon, 1 Jun 2026 11:32:17 +0200 Subject: [PATCH] feat: compliance report + auto-versioning for configurations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- .../configurations_v1_controller.ts | 34 +++- app/domain/configuration/compliance.ts | 5 +- app/models/configuration.ts | 6 + app/models/device.ts | 61 +++++- app/services/configuration_engine.ts | 106 +++++++--- app/services/configuration_service.ts | 68 ++++++- start/routes.ts | 2 + .../services/configuration_engine.spec.ts | 175 ++++++++++++++++ .../services/configuration_service.spec.ts | 188 +++++++++++++++++- 9 files changed, 598 insertions(+), 47 deletions(-) create mode 100644 tests/unit/services/configuration_engine.spec.ts diff --git a/app/controllers/configurations_v1_controller.ts b/app/controllers/configurations_v1_controller.ts index 0b98594..38cfe92 100644 --- a/app/controllers/configurations_v1_controller.ts +++ b/app/controllers/configurations_v1_controller.ts @@ -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 diff --git a/app/domain/configuration/compliance.ts b/app/domain/configuration/compliance.ts index 33d3ca2..cfe24ed 100644 --- a/app/domain/configuration/compliance.ts +++ b/app/domain/configuration/compliance.ts @@ -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 } diff --git a/app/models/configuration.ts b/app/models/configuration.ts index bd4eba4..d88ace6 100644 --- a/app/models/configuration.ts +++ b/app/models/configuration.ts @@ -31,6 +31,12 @@ export default class ConfigurationModel extends BaseModel { return this.create(data) as Promise } + static async distinctTags(): Promise { + const collection = this.getCollection() + const tags = await collection.distinct('tags') + return (tags as string[]).filter(Boolean).sort() + } + static async deleteByIdentifier(identifier: string): Promise { return this.deleteOne({ identifier }) } diff --git a/app/models/device.ts b/app/models/device.ts index f8159b4..dd3d245 100644 --- a/app/models/device.ts +++ b/app/models/device.ts @@ -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 => { + 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 { + const collection = this.getCollection() + await collection.updateMany( + { 'compliance.configurationIdentifier': configurationIdentifier }, + { + $set: { + 'compliance.status': 'pending', + 'compliance.lastError': null, + updatedAt: new Date(), + }, + } + ) + } } diff --git a/app/services/configuration_engine.ts b/app/services/configuration_engine.ts index 7b3958b..0e3905e 100644 --- a/app/services/configuration_engine.ts +++ b/app/services/configuration_engine.ts @@ -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 { + async evaluateSpecific(device: Device, config: Configuration): Promise { // 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 { + 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, + } + } } diff --git a/app/services/configuration_service.ts b/app/services/configuration_service.ts index 77c7f8b..94e8039 100644 --- a/app/services/configuration_service.ts +++ b/app/services/configuration_service.ts @@ -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> +export type UpdateConfigurationDto = Partial> & { + 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 { 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 = { @@ -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 = {} + 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. diff --git a/start/routes.ts b/start/routes.ts index 31ee0dc..1a9f86b 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -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']) diff --git a/tests/unit/services/configuration_engine.spec.ts b/tests/unit/services/configuration_engine.spec.ts new file mode 100644 index 0000000..8719546 --- /dev/null +++ b/tests/unit/services/configuration_engine.spec.ts @@ -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([]) + }) + }) +}) diff --git a/tests/unit/services/configuration_service.spec.ts b/tests/unit/services/configuration_service.spec.ts index 3139546..ec93a74 100644 --- a/tests/unit/services/configuration_service.spec.ts +++ b/tests/unit/services/configuration_service.spec.ts @@ -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 { 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)