- 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>
600 lines
20 KiB
TypeScript
600 lines
20 KiB
TypeScript
import ConfigurationModel from '#models/configuration'
|
|
import ConfigurationVersionModel from '#models/configuration_version'
|
|
import DeviceModel from '#models/device'
|
|
import {
|
|
type Configuration,
|
|
type ConfigurationCriteria,
|
|
type DesiredParameter,
|
|
type InventoryPath,
|
|
PolicyType,
|
|
} from '../domain/configuration/configuration.js'
|
|
import type { ConfigurationVersion } from '../domain/configuration/configuration_version.js'
|
|
import { isParameterCoveredByInventory, suggestInventoryPath } from '../utils/inventory_coverage.js'
|
|
import { matchGlob } from '../utils/glob_match.js'
|
|
|
|
/**
|
|
* Allowed criteria fields — DeviceId attributes (immutable) + parameterCriteria (evaluated dynamically).
|
|
*/
|
|
const ALLOWED_CRITERIA_FIELDS: ReadonlySet<string> = new Set([
|
|
'manufacturer',
|
|
'oui',
|
|
'productClass',
|
|
'serialNumber',
|
|
'parameterCriteria',
|
|
])
|
|
|
|
const IDENTIFIER_REGEX = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
|
|
const SEMVER_REGEX = /^\d+\.\d+\.\d+$/
|
|
|
|
export interface ConfigurationFilters {
|
|
page?: number
|
|
perPage?: number
|
|
tag?: string
|
|
name?: string
|
|
enabled?: boolean
|
|
productClass?: string
|
|
}
|
|
|
|
export interface CreateConfigurationDto {
|
|
identifier: string
|
|
name: string
|
|
version: string
|
|
priority: number
|
|
policy: PolicyType | string
|
|
enabled?: boolean
|
|
tags?: string[]
|
|
criteria: ConfigurationCriteria
|
|
inventory: InventoryPath[]
|
|
desiredParameters: DesiredParameter[]
|
|
firmwareTarget?: { productClass: string; targetVersion: string }
|
|
schedule?: {
|
|
daysOfWeek: number[]
|
|
startHour: number
|
|
endHour: number
|
|
timezone: string
|
|
}
|
|
}
|
|
|
|
export type UpdateConfigurationDto = Partial<Omit<CreateConfigurationDto, 'identifier'>> & {
|
|
bumpType?: 'patch' | 'minor' | 'major'
|
|
}
|
|
|
|
export interface OverlapWarning {
|
|
type: 'overlap_different_priority'
|
|
overlappingConfigurations: Array<{ identifier: string; priority: number }>
|
|
}
|
|
|
|
export type OverlapResult = OverlapWarning | null
|
|
|
|
export class ValidationError extends Error {
|
|
public readonly field: string
|
|
public readonly statusCode: number
|
|
|
|
constructor(message: string, field: string = '', statusCode: number = 422) {
|
|
super(message)
|
|
this.name = 'ValidationError'
|
|
this.field = field
|
|
this.statusCode = statusCode
|
|
}
|
|
}
|
|
|
|
export default class ConfigurationService {
|
|
/**
|
|
* Create a new Configuration after validation.
|
|
*/
|
|
async create(data: CreateConfigurationDto): Promise<Configuration & { warning?: OverlapWarning }> {
|
|
this.validateIdentifier(data.identifier)
|
|
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)
|
|
|
|
// Detect overlap — throws on same-priority conflict, returns warning for different-priority
|
|
const overlapResult = await this.detectOverlap(data.criteria, data.priority)
|
|
|
|
const configuration: Partial<Configuration> = {
|
|
identifier: data.identifier,
|
|
name: data.name,
|
|
version: data.version,
|
|
priority: data.priority,
|
|
policy: data.policy as PolicyType,
|
|
enabled: data.enabled ?? true,
|
|
tags: data.tags ?? [],
|
|
criteria: data.criteria,
|
|
inventory: data.inventory,
|
|
desiredParameters: data.desiredParameters,
|
|
firmwareTarget: data.firmwareTarget,
|
|
schedule: data.schedule,
|
|
}
|
|
|
|
const created = await ConfigurationModel.createIfNotExists(configuration)
|
|
|
|
if (overlapResult) {
|
|
return { ...created, warning: overlapResult }
|
|
}
|
|
|
|
return created
|
|
}
|
|
|
|
/**
|
|
* Return a paginated list of Configurations with optional filters.
|
|
*/
|
|
async findAll(filters: ConfigurationFilters = {}) {
|
|
const page = filters.page ?? 1
|
|
const perPage = filters.perPage ?? 25
|
|
const mongoFilter: Record<string, any> = {}
|
|
|
|
if (filters.tag) {
|
|
mongoFilter.tags = filters.tag
|
|
}
|
|
|
|
if (filters.name) {
|
|
mongoFilter.name = { $regex: filters.name, $options: 'i' }
|
|
}
|
|
|
|
if (filters.enabled !== undefined) {
|
|
mongoFilter.enabled = filters.enabled
|
|
}
|
|
|
|
if (filters.productClass) {
|
|
mongoFilter['criteria.productClass'] = filters.productClass
|
|
}
|
|
|
|
return ConfigurationModel.paginateConfigurations(page, perPage, mongoFilter)
|
|
}
|
|
|
|
/**
|
|
* Find a Configuration by its unique identifier.
|
|
*/
|
|
async findById(identifier: string): Promise<Configuration | null> {
|
|
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)
|
|
if (!existing) {
|
|
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
|
|
}
|
|
|
|
if (data.priority !== undefined) {
|
|
this.validatePriority(data.priority)
|
|
}
|
|
if (data.policy !== undefined) {
|
|
this.validatePolicy(data.policy)
|
|
}
|
|
if (data.criteria !== undefined) {
|
|
data.criteria = this.sanitizeCriteria(data.criteria)
|
|
this.validateCriteria(data.criteria)
|
|
}
|
|
|
|
// Validate inventory coverage with merged data
|
|
const finalDesiredParameters = data.desiredParameters ?? existing.desiredParameters
|
|
const finalInventory = data.inventory ?? existing.inventory
|
|
const finalCriteria2 = data.criteria ?? existing.criteria
|
|
this.validateParameterCriteriaDeadlock(finalCriteria2, finalDesiredParameters)
|
|
this.validateInventoryCoverage(finalDesiredParameters, finalInventory)
|
|
|
|
// Detect overlap if criteria or priority changed
|
|
let overlapResult: OverlapResult = null
|
|
const finalCriteria = data.criteria ?? existing.criteria
|
|
const finalPriority = data.priority ?? existing.priority
|
|
if (data.criteria !== undefined || data.priority !== undefined) {
|
|
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'> = {
|
|
configurationIdentifier: identifier,
|
|
versionNumber: latestVersionNumber + 1,
|
|
snapshot: existing,
|
|
modifiedAt: new Date(),
|
|
}
|
|
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.priority !== undefined) updatePayload.priority = data.priority
|
|
if (data.policy !== undefined) updatePayload.policy = data.policy
|
|
if (data.enabled !== undefined) updatePayload.enabled = data.enabled
|
|
if (data.tags !== undefined) updatePayload.tags = data.tags
|
|
if (data.criteria !== undefined) updatePayload.criteria = data.criteria
|
|
if (data.inventory !== undefined) updatePayload.inventory = data.inventory
|
|
if (data.desiredParameters !== undefined) updatePayload.desiredParameters = data.desiredParameters
|
|
if (data.firmwareTarget !== undefined) updatePayload.firmwareTarget = data.firmwareTarget
|
|
if (data.schedule !== undefined) updatePayload.schedule = data.schedule
|
|
|
|
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) {
|
|
return { ...updated!, warning: overlapResult }
|
|
}
|
|
|
|
return updated!
|
|
}
|
|
|
|
/**
|
|
* Get the version history of a Configuration with pagination.
|
|
* Returns versions in reverse chronological order.
|
|
*/
|
|
async getHistory(
|
|
identifier: string,
|
|
page: number = 1,
|
|
limit: number = 25
|
|
): Promise<{ data: ConfigurationVersion[]; total: number; page: number; perPage: number; lastPage: number }> {
|
|
const existing = await ConfigurationModel.findByIdentifier(identifier)
|
|
if (!existing) {
|
|
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
|
|
}
|
|
|
|
return ConfigurationVersionModel.paginateVersions(identifier, page, limit)
|
|
}
|
|
|
|
/**
|
|
* Toggle the enabled flag of a Configuration without modifying other fields.
|
|
*/
|
|
async toggleEnabled(identifier: string, enabled: boolean): Promise<Configuration> {
|
|
const existing = await ConfigurationModel.findByIdentifier(identifier)
|
|
if (!existing) {
|
|
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
|
|
}
|
|
|
|
await ConfigurationModel.updateOne({ identifier }, { enabled })
|
|
|
|
const updated = await ConfigurationModel.findByIdentifier(identifier)
|
|
return updated!
|
|
}
|
|
|
|
/**
|
|
* Add and/or remove tags from a Configuration without modifying other fields.
|
|
*/
|
|
async updateTags(
|
|
identifier: string,
|
|
add?: string[],
|
|
remove?: string[]
|
|
): Promise<Configuration> {
|
|
const existing = await ConfigurationModel.findByIdentifier(identifier)
|
|
if (!existing) {
|
|
throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404)
|
|
}
|
|
|
|
let tags = [...existing.tags]
|
|
|
|
// Add tags (avoid duplicates)
|
|
if (add && add.length > 0) {
|
|
for (const tag of add) {
|
|
if (!tags.includes(tag)) {
|
|
tags.push(tag)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove tags
|
|
if (remove && remove.length > 0) {
|
|
tags = tags.filter((tag) => !remove.includes(tag))
|
|
}
|
|
|
|
await ConfigurationModel.updateOne({ identifier }, { tags })
|
|
|
|
const updated = await ConfigurationModel.findByIdentifier(identifier)
|
|
return updated!
|
|
}
|
|
|
|
/**
|
|
* Delete a Configuration and all its associated versions.
|
|
*/
|
|
async delete(identifier: string): Promise<boolean> {
|
|
const deleted = await ConfigurationModel.deleteByIdentifier(identifier)
|
|
if (deleted) {
|
|
await ConfigurationVersionModel.deleteByConfigurationIdentifier(identifier)
|
|
}
|
|
return deleted
|
|
}
|
|
|
|
// ─── Overlap Detection ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Detect overlap between a Configuration's criteria and existing enabled Configurations.
|
|
*
|
|
* Two configurations overlap if their criteria could match the same device.
|
|
* - If both define the same field, check if patterns could match overlapping values
|
|
* - If one doesn't define a field, it matches all values → potential overlap
|
|
*
|
|
* @param criteria - The criteria to check for overlap
|
|
* @param priority - The priority of the configuration being created/updated
|
|
* @param excludeId - Optional identifier to exclude (for updates)
|
|
* @returns OverlapWarning if different-priority overlap found, null if no overlap
|
|
* @throws ValidationError with 409 if same-priority overlap found
|
|
*/
|
|
async detectOverlap(
|
|
criteria: ConfigurationCriteria,
|
|
priority: number,
|
|
excludeId?: string
|
|
): Promise<OverlapResult> {
|
|
const allEnabled = await ConfigurationModel.findAllEnabled()
|
|
|
|
const samePriorityConflicts: Array<{ identifier: string; priority: number }> = []
|
|
const differentPriorityOverlaps: Array<{ identifier: string; priority: number }> = []
|
|
|
|
for (const existing of allEnabled) {
|
|
// Skip the configuration being updated
|
|
if (excludeId && existing.identifier === excludeId) {
|
|
continue
|
|
}
|
|
|
|
if (this.criteriaOverlap(criteria, existing.criteria)) {
|
|
if (existing.priority === priority) {
|
|
samePriorityConflicts.push({
|
|
identifier: existing.identifier,
|
|
priority: existing.priority,
|
|
})
|
|
} else {
|
|
differentPriorityOverlaps.push({
|
|
identifier: existing.identifier,
|
|
priority: existing.priority,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Same-priority overlap → reject with 409
|
|
if (samePriorityConflicts.length > 0) {
|
|
const identifiers = samePriorityConflicts.map((c) => c.identifier).join(', ')
|
|
throw new ValidationError(
|
|
`Priority conflict: configurations with the same priority ${priority} have overlapping criteria: ${identifiers}`,
|
|
'priority',
|
|
409
|
|
)
|
|
}
|
|
|
|
// Different-priority overlap → return warning
|
|
if (differentPriorityOverlaps.length > 0) {
|
|
return {
|
|
type: 'overlap_different_priority',
|
|
overlappingConfigurations: differentPriorityOverlaps,
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Check if two sets of criteria could potentially match the same device.
|
|
*
|
|
* Conservative approach:
|
|
* - If one criteria doesn't define a field, it matches ALL values → potential overlap
|
|
* - If both define the same field, check if patterns could overlap using glob matching
|
|
*/
|
|
private criteriaOverlap(
|
|
criteriaA: ConfigurationCriteria,
|
|
criteriaB: ConfigurationCriteria
|
|
): boolean {
|
|
const fields: Array<keyof ConfigurationCriteria> = [
|
|
'manufacturer',
|
|
'oui',
|
|
'productClass',
|
|
'serialNumber',
|
|
]
|
|
|
|
for (const field of fields) {
|
|
const patternA = criteriaA[field] as string | undefined
|
|
const patternB = criteriaB[field] as string | undefined
|
|
|
|
// If either doesn't define the field, it matches all → potential overlap on this field
|
|
if (!patternA || !patternB) {
|
|
continue
|
|
}
|
|
|
|
// Both define the field — check if patterns could match overlapping values
|
|
if (!this.patternsCouldOverlap(patternA, patternB)) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// All fields are potentially overlapping
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Determine if two glob patterns could match overlapping sets of values.
|
|
*
|
|
* Conservative approach: if one pattern matches the other (or vice versa),
|
|
* they could overlap. Also, if both are identical, they definitely overlap.
|
|
* For complex cases, we assume potential overlap (conservative).
|
|
*/
|
|
private patternsCouldOverlap(patternA: string, patternB: string): boolean {
|
|
// Identical patterns always overlap
|
|
if (patternA === patternB) {
|
|
return true
|
|
}
|
|
|
|
// Check if one pattern could match a value that the other also matches.
|
|
// Simple heuristic: try matching each pattern against the other as a literal value.
|
|
// If patternA matches patternB (as a value) or vice versa, they could overlap.
|
|
if (matchGlob(patternA, patternB) || matchGlob(patternB, patternA)) {
|
|
return true
|
|
}
|
|
|
|
// If either contains wildcards, be conservative and check for prefix overlap
|
|
const hasWildcardA = patternA.includes('*') || patternA.includes('?')
|
|
const hasWildcardB = patternB.includes('*') || patternB.includes('?')
|
|
|
|
if (hasWildcardA || hasWildcardB) {
|
|
// Extract the fixed prefix before the first wildcard
|
|
const prefixA = patternA.split(/[*?]/)[0]
|
|
const prefixB = patternB.split(/[*?]/)[0]
|
|
|
|
// If one prefix starts with the other, patterns could overlap
|
|
if (prefixA.startsWith(prefixB) || prefixB.startsWith(prefixA)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// No overlap detected
|
|
return false
|
|
}
|
|
|
|
// ─── Validation Methods ──────────────────────────────────────────────────────
|
|
|
|
private validateIdentifier(identifier: string): void {
|
|
if (!identifier || !IDENTIFIER_REGEX.test(identifier)) {
|
|
throw new ValidationError(
|
|
`Invalid identifier '${identifier}'. Must start with alphanumeric and contain only alphanumeric, hyphens, or underscores.`,
|
|
'identifier'
|
|
)
|
|
}
|
|
}
|
|
|
|
private validateVersion(version: string): void {
|
|
if (!version || !SEMVER_REGEX.test(version)) {
|
|
throw new ValidationError(
|
|
`Invalid version '${version}'. Must be a valid semver format (e.g. "1.0.0").`,
|
|
'version'
|
|
)
|
|
}
|
|
}
|
|
|
|
private validatePriority(priority: number): void {
|
|
if (!Number.isInteger(priority) || priority <= 0) {
|
|
throw new ValidationError(
|
|
`Invalid priority '${priority}'. Must be a positive integer greater than 0.`,
|
|
'priority'
|
|
)
|
|
}
|
|
}
|
|
|
|
private validatePolicy(policy: string): void {
|
|
if (policy !== PolicyType.ONCE && policy !== PolicyType.MAINTAIN) {
|
|
throw new ValidationError(
|
|
`Invalid policy '${policy}'. Must be "once" or "maintain".`,
|
|
'policy'
|
|
)
|
|
}
|
|
}
|
|
|
|
private validateCriteria(criteria: ConfigurationCriteria): void {
|
|
const fields = Object.keys(criteria)
|
|
for (const field of fields) {
|
|
if (!ALLOWED_CRITERIA_FIELDS.has(field)) {
|
|
throw new ValidationError(
|
|
`Invalid criteria field '${field}'. Only DeviceId fields are allowed: ${[...ALLOWED_CRITERIA_FIELDS].join(', ')}.`,
|
|
'criteria'
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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.
|
|
* We throw a ValidationError to inform the caller of this dangerous overlap.
|
|
*/
|
|
private validateParameterCriteriaDeadlock(
|
|
criteria: ConfigurationCriteria,
|
|
desiredParameters: DesiredParameter[]
|
|
): void {
|
|
const pcNames = (criteria.parameterCriteria || []).map((p) => p.name)
|
|
const dpNames = new Set((desiredParameters || []).map((p) => p.name))
|
|
const conflicts = pcNames.filter((name) => dpNames.has(name))
|
|
if (conflicts.length > 0) {
|
|
throw new ValidationError(
|
|
`Deadlock risk: parameter(s) [${conflicts.join(', ')}] appear in both criteria.parameterCriteria and desiredParameters. ` +
|
|
`A device that does not yet have these values will never match, and the config will never apply.`,
|
|
'criteria.parameterCriteria'
|
|
)
|
|
}
|
|
}
|
|
|
|
private validateInventoryCoverage(
|
|
desiredParameters: DesiredParameter[],
|
|
inventory: InventoryPath[]
|
|
): void {
|
|
if (!desiredParameters || desiredParameters.length === 0) {
|
|
return
|
|
}
|
|
|
|
const uncoveredParams: Array<{ name: string; suggestedPath: string }> = []
|
|
|
|
for (const param of desiredParameters) {
|
|
if (!isParameterCoveredByInventory(param.name, inventory)) {
|
|
uncoveredParams.push({
|
|
name: param.name,
|
|
suggestedPath: suggestInventoryPath(param.name),
|
|
})
|
|
}
|
|
}
|
|
|
|
if (uncoveredParams.length > 0) {
|
|
const details = uncoveredParams
|
|
.map((p) => `"${p.name}" (suggest adding "${p.suggestedPath}" to inventory)`)
|
|
.join(', ')
|
|
throw new ValidationError(
|
|
`Desired parameters not covered by inventory: ${details}`,
|
|
'desiredParameters'
|
|
)
|
|
}
|
|
}
|
|
}
|