- Docker infrastructure (Dockerfile, docker-compose.yml, genieacs-sim) - AdonisJS v7 app with Inertia.js + Vue 3 - MongoDB integration - CWMP/TR-069 state machine engine - Configuration engine with compliance tracking - Branding: ADAM ACS (favicon, navbar, locales) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import fc from 'fast-check'
|
|
import MigrationService from '../../app/services/migration_service.js'
|
|
import type { CreateConfigurationDto } from '../../app/services/configuration_service.js'
|
|
|
|
// Mock services
|
|
vi.mock('../../app/services/configuration_service.js', () => {
|
|
return {
|
|
default: class ConfigurationService {
|
|
private configurations: Map<string, any> = new Map()
|
|
|
|
async findById(id: string) {
|
|
return this.configurations.get(id) || null
|
|
}
|
|
|
|
async findAll() {
|
|
return {
|
|
data: Array.from(this.configurations.values()),
|
|
}
|
|
}
|
|
|
|
async create(dto: CreateConfigurationDto) {
|
|
this.configurations.set(dto.identifier, {
|
|
...dto,
|
|
id: dto.identifier,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
return this.configurations.get(dto.identifier)
|
|
}
|
|
},
|
|
}
|
|
})
|
|
|
|
vi.mock('../../app/services/firmware_catalog_service.js', () => {
|
|
return {
|
|
default: class FirmwareCatalogService {
|
|
async createEntry() {
|
|
return {}
|
|
}
|
|
},
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Property 14: Migration preserves semantics
|
|
*
|
|
* For any legacy Discovery configuration with modelPaths M and criteria C,
|
|
* the migration SHALL produce a Configuration with inventory paths derived
|
|
* from M, criteria derived from C's DEVICE_ID entries, policy "maintain",
|
|
* and empty desiredParameters. For any legacy Provisioning configuration
|
|
* with SET_PARAMETER_VALUES actions, the migration SHALL produce a
|
|
* Configuration with desiredParameters derived from those actions and
|
|
* policy "once". In both cases, the original identifier (prefixed) and
|
|
* creationDate SHALL be preserved.
|
|
*
|
|
* Validates: Requirements 14.1, 14.2, 14.3, 14.4
|
|
*/
|
|
describe('Property 14: Migration preserves semantics', () => {
|
|
let mockDb: any
|
|
|
|
beforeEach(() => {
|
|
mockDb = {
|
|
collection: vi.fn((_name: string) => {
|
|
return {
|
|
find: vi.fn(() => ({
|
|
toArray: vi.fn(async () => []),
|
|
})),
|
|
insertOne: vi.fn(async () => ({})),
|
|
}
|
|
}),
|
|
}
|
|
|
|
new MigrationService(mockDb)
|
|
})
|
|
|
|
/**
|
|
* Property 14.1: Discovery migration preserves semantics
|
|
*
|
|
* For any legacy Discovery config with:
|
|
* - identifier
|
|
* - modelPaths (array of strings)
|
|
* - criteria (manufacturer, oui, productClass, serialNumber)
|
|
* - creationDate
|
|
*
|
|
* The migrated Configuration SHALL have:
|
|
* - identifier prefixed with "legacy-disc-"
|
|
* - inventory paths derived from modelPaths
|
|
* - criteria matching the legacy criteria
|
|
* - policy "maintain"
|
|
* - empty desiredParameters
|
|
*/
|
|
it('Property 14.1: Discovery migration preserves inventory paths, criteria, and policy', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.record({
|
|
identifier: fc.string({ minLength: 1 }),
|
|
name: fc.string(),
|
|
modelPaths: fc.array(fc.string({ minLength: 1 }), { minLength: 1 }),
|
|
manufacturer: fc.option(fc.string()),
|
|
oui: fc.option(fc.string()),
|
|
productClass: fc.option(fc.string()),
|
|
serialNumber: fc.option(fc.string()),
|
|
createdAt: fc.date(),
|
|
}),
|
|
async (legacyConfig) => {
|
|
// Create a mock discovery config
|
|
const mockCollection = {
|
|
find: vi.fn(() => ({
|
|
toArray: vi.fn(async () => [legacyConfig]),
|
|
})),
|
|
}
|
|
const mockDb = {
|
|
collection: vi.fn((name: string) => {
|
|
if (name === 'discovery_configs') return mockCollection
|
|
return {
|
|
find: vi.fn(() => ({
|
|
toArray: vi.fn(async () => []),
|
|
})),
|
|
}
|
|
}),
|
|
}
|
|
|
|
// Create a custom config service that captures created configs
|
|
const createdConfigs: CreateConfigurationDto[] = []
|
|
const mockConfigService = {
|
|
async findById() {
|
|
return null
|
|
},
|
|
async create(dto: CreateConfigurationDto) {
|
|
createdConfigs.push(dto)
|
|
return { ...dto, id: dto.identifier }
|
|
},
|
|
async findAll() {
|
|
return { data: [] }
|
|
},
|
|
}
|
|
|
|
const service = new MigrationService(mockDb as any, mockConfigService as any)
|
|
const report = await service.migrateAll()
|
|
|
|
// Should have migrated exactly one discovery config
|
|
expect(report.discoveryMigrated).toBe(1)
|
|
expect(report.discoverySkipped).toBe(0)
|
|
expect(createdConfigs).toHaveLength(1)
|
|
|
|
const migrated = createdConfigs[0]
|
|
|
|
// Verify identifier is prefixed
|
|
expect(migrated.identifier).toBe(`legacy-disc-${legacyConfig.identifier}`)
|
|
|
|
// Verify inventory paths are derived from modelPaths
|
|
expect(migrated.inventory).toHaveLength(legacyConfig.modelPaths.length)
|
|
for (let i = 0; i < legacyConfig.modelPaths.length; i++) {
|
|
expect(migrated.inventory[i]).toEqual({ path: legacyConfig.modelPaths[i] })
|
|
}
|
|
|
|
// Verify criteria match legacy values (only truthy values)
|
|
;(['manufacturer', 'oui', 'productClass', 'serialNumber'] as const).forEach((key) => {
|
|
const legacyValue = (legacyConfig as any)[key]
|
|
if (legacyValue) {
|
|
expect(migrated.criteria[key]).toBe(legacyValue)
|
|
} else {
|
|
expect(migrated.criteria[key]).toBeUndefined()
|
|
}
|
|
})
|
|
expect(migrated.policy).toBe('maintain')
|
|
|
|
// Verify desiredParameters is empty
|
|
expect(migrated.desiredParameters).toEqual([])
|
|
|
|
// Verify tags include "legacy" and "discovery"
|
|
expect(migrated.tags).toContain('legacy')
|
|
expect(migrated.tags).toContain('discovery')
|
|
}
|
|
),
|
|
{ numRuns: 50 }
|
|
)
|
|
})
|
|
|
|
/**
|
|
* Property 14.2: Provisioning migration preserves semantics
|
|
*
|
|
* For any legacy Provisioning config with:
|
|
* - identifier
|
|
* - actions (array of SET_PARAMETER actions)
|
|
* - criteria (manufacturer, oui, productClass, serialNumber)
|
|
* - downloadActions (optional, determines policy)
|
|
* - creationDate
|
|
*
|
|
* The migrated Configuration SHALL have:
|
|
* - identifier prefixed with "legacy-prov-"
|
|
* - desiredParameters derived from SET_PARAMETER actions
|
|
* - policy "once" if downloadActions exist, "maintain" otherwise
|
|
* - criteria matching the legacy criteria
|
|
* - tags including "legacy" and "provisioning"
|
|
*/
|
|
it('Property 14.2: Provisioning migration preserves parameters, policy, and criteria', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.record({
|
|
identifier: fc.string({ minLength: 1 }),
|
|
name: fc.string(),
|
|
manufacturer: fc.option(fc.string()),
|
|
oui: fc.option(fc.string()),
|
|
productClass: fc.option(fc.string()),
|
|
serialNumber: fc.option(fc.string()),
|
|
actions: fc.array(
|
|
fc.record({
|
|
type: fc.constant('SET_PARAMETER'),
|
|
parameter: fc.string({ minLength: 1 }),
|
|
value: fc.oneof(fc.string(), fc.integer(), fc.boolean()),
|
|
}),
|
|
{ minLength: 1 }
|
|
),
|
|
downloadActions: fc.array(fc.record({ version: fc.string() }), {
|
|
minLength: 0,
|
|
maxLength: 2,
|
|
}),
|
|
createdAt: fc.date(),
|
|
}),
|
|
async (legacyConfig) => {
|
|
// Create a mock provisioning config
|
|
const mockCollection = {
|
|
find: vi.fn(() => ({
|
|
toArray: vi.fn(async () => [legacyConfig]),
|
|
})),
|
|
}
|
|
const mockDb = {
|
|
collection: vi.fn((name: string) => {
|
|
if (name === 'provisioning_configs') return mockCollection
|
|
return {
|
|
find: vi.fn(() => ({
|
|
toArray: vi.fn(async () => []),
|
|
})),
|
|
}
|
|
}),
|
|
}
|
|
|
|
// Create a custom config service that captures created configs
|
|
const createdConfigs: CreateConfigurationDto[] = []
|
|
const mockConfigService = {
|
|
async findById() {
|
|
return null
|
|
},
|
|
async create(dto: CreateConfigurationDto) {
|
|
createdConfigs.push(dto)
|
|
return { ...dto, id: dto.identifier }
|
|
},
|
|
async findAll() {
|
|
return { data: [] }
|
|
},
|
|
}
|
|
|
|
const service = new MigrationService(mockDb as any, mockConfigService as any)
|
|
const report = await service.migrateAll()
|
|
|
|
// Should have migrated exactly one provisioning config
|
|
expect(report.provisioningMigrated).toBe(1)
|
|
expect(report.provisioningSkipped).toBe(0)
|
|
expect(createdConfigs).toHaveLength(1)
|
|
|
|
const migrated = createdConfigs[0]
|
|
|
|
// Verify identifier is prefixed
|
|
expect(migrated.identifier).toBe(`legacy-prov-${legacyConfig.identifier}`)
|
|
|
|
// Verify desiredParameters are derived from SET_PARAMETER actions
|
|
expect(migrated.desiredParameters).toHaveLength(legacyConfig.actions.length)
|
|
for (let i = 0; i < legacyConfig.actions.length; i++) {
|
|
const param = migrated.desiredParameters[i]
|
|
const action = legacyConfig.actions[i]
|
|
expect(param.name).toBe(action.parameter)
|
|
expect(param.value).toBe(action.value)
|
|
}
|
|
|
|
// Verify policy based on downloadActions
|
|
const expectedPolicy = legacyConfig.downloadActions.length > 0 ? 'once' : 'maintain'
|
|
expect(migrated.policy).toBe(expectedPolicy)
|
|
|
|
// Verify criteria match legacy values (only truthy values)
|
|
;(['manufacturer', 'oui', 'productClass', 'serialNumber'] as const).forEach((key) => {
|
|
const legacyValue = (legacyConfig as any)[key]
|
|
if (legacyValue) {
|
|
expect(migrated.criteria[key]).toBe(legacyValue)
|
|
} else {
|
|
expect(migrated.criteria[key]).toBeUndefined()
|
|
}
|
|
})
|
|
expect(migrated.tags).toContain('legacy')
|
|
expect(migrated.tags).toContain('provisioning')
|
|
}
|
|
),
|
|
{ numRuns: 50 }
|
|
)
|
|
})
|
|
|
|
/**
|
|
* Property 14.3: Migration preserves identifier uniqueness
|
|
*
|
|
* If two different legacy configs have the same identifier, the second
|
|
* migration should be skipped (not overwrite the first).
|
|
*/
|
|
it('Property 14.3: Migration skips duplicate identifiers', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.record({
|
|
identifier: fc.string({ minLength: 1 }),
|
|
modelPaths: fc.array(fc.string({ minLength: 1 }), { minLength: 1 }),
|
|
}),
|
|
async (legacyConfig) => {
|
|
// Create a config service that returns the first config as already existing
|
|
const mockDb = {
|
|
collection: vi.fn((name: string) => ({
|
|
find: vi.fn(() => ({
|
|
toArray: vi.fn(async () => {
|
|
if (name === 'discovery_configs') {
|
|
// Return the same config twice to test duplicate handling
|
|
return [legacyConfig, legacyConfig]
|
|
}
|
|
return []
|
|
}),
|
|
})),
|
|
})),
|
|
}
|
|
|
|
const createdConfigs: CreateConfigurationDto[] = []
|
|
let callCount = 0
|
|
const mockConfigService = {
|
|
async findById(id: string) {
|
|
// First call returns null (not exists), second call returns the config
|
|
if (callCount > 0) {
|
|
return { identifier: id }
|
|
}
|
|
return null
|
|
},
|
|
async create(dto: CreateConfigurationDto) {
|
|
callCount++
|
|
createdConfigs.push(dto)
|
|
return { ...dto, id: dto.identifier }
|
|
},
|
|
async findAll() {
|
|
return { data: [] }
|
|
},
|
|
}
|
|
|
|
const service = new MigrationService(mockDb as any, mockConfigService as any)
|
|
const report = await service.migrateAll()
|
|
|
|
// First config created, second skipped
|
|
expect(report.discoveryMigrated).toBe(1)
|
|
expect(report.discoverySkipped).toBe(1)
|
|
expect(createdConfigs).toHaveLength(1)
|
|
}
|
|
),
|
|
{ numRuns: 30 }
|
|
)
|
|
})
|
|
|
|
/**
|
|
* Property 14.4: Criteria coverage
|
|
*
|
|
* When legacy config has partial criteria (e.g., only manufacturer),
|
|
* the migrated Configuration should preserve exactly those fields
|
|
* (not add missing fields with defaults).
|
|
*/
|
|
it('Property 14.4: Partial criteria preserved exactly', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
fc.record({
|
|
identifier: fc.string({ minLength: 1 }),
|
|
modelPaths: fc.array(fc.string({ minLength: 1 }), { minLength: 1 }),
|
|
manufacturer: fc.option(fc.string()),
|
|
oui: fc.option(fc.string()),
|
|
productClass: fc.option(fc.string()),
|
|
serialNumber: fc.option(fc.string()),
|
|
}),
|
|
async (legacyConfig) => {
|
|
const mockDb = {
|
|
collection: vi.fn((name: string) => ({
|
|
find: vi.fn(() => ({
|
|
toArray: vi.fn(async () => {
|
|
if (name === 'discovery_configs') return [legacyConfig]
|
|
return []
|
|
}),
|
|
})),
|
|
})),
|
|
}
|
|
|
|
const createdConfigs: CreateConfigurationDto[] = []
|
|
const mockConfigService = {
|
|
async findById() {
|
|
return null
|
|
},
|
|
async create(dto: CreateConfigurationDto) {
|
|
createdConfigs.push(dto)
|
|
return { ...dto, id: dto.identifier }
|
|
},
|
|
async findAll() {
|
|
return { data: [] }
|
|
},
|
|
}
|
|
|
|
const service = new MigrationService(mockDb as any, mockConfigService as any)
|
|
await service.migrateAll()
|
|
|
|
const migrated = createdConfigs[0]
|
|
|
|
// Verify criteria only contain truthy values from legacy
|
|
Object.entries(migrated.criteria).forEach(([key]) => {
|
|
// Each criterion in migrated must come from a truthy value in legacy
|
|
expect((legacyConfig as any)[key]).toBeTruthy()
|
|
})
|
|
|
|
// Verify all truthy legacy criteria are in migrated
|
|
;(['manufacturer', 'oui', 'productClass', 'serialNumber'] as const).forEach((key) => {
|
|
const legacyValue = (legacyConfig as any)[key]
|
|
if (legacyValue) {
|
|
// Truthy values must be in criteria
|
|
expect(migrated.criteria[key]).toBe(legacyValue)
|
|
} else {
|
|
// Falsy values must NOT be in criteria
|
|
expect(migrated.criteria[key]).toBeUndefined()
|
|
}
|
|
})
|
|
}
|
|
),
|
|
{ numRuns: 50 }
|
|
)
|
|
})
|
|
})
|