- 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>
298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import * as fc from 'fast-check'
|
|
import { PolicyType } from '../../app/domain/configuration/configuration.js'
|
|
import type {
|
|
Configuration,
|
|
ConfigurationCriteria,
|
|
DesiredParameter,
|
|
InventoryPath,
|
|
} from '../../app/domain/configuration/configuration.js'
|
|
import type { ConfigurationVersion } from '../../app/domain/configuration/configuration_version.js'
|
|
|
|
/**
|
|
* **Validates: Requirements 15.1, 15.2, 15.5**
|
|
*
|
|
* Property 15: Version history invariants
|
|
*
|
|
* Properties verified:
|
|
* 1. Each update creates exactly one new version snapshot
|
|
* 2. Version numbers are strictly increasing (monotonic)
|
|
* 3. The snapshot contains the state BEFORE the modification
|
|
* 4. Deleting a configuration removes all its versions
|
|
*/
|
|
|
|
// --- Mutable container that won't be affected by hoisting ---
|
|
const store = {
|
|
configurations: new Map<string, Configuration>(),
|
|
versions: [] as ConfigurationVersion[],
|
|
}
|
|
|
|
// --- Mock setup ---
|
|
|
|
vi.mock('../../app/models/configuration.js', () => ({
|
|
default: {
|
|
findByIdentifier: vi.fn((identifier: string) => {
|
|
const found = store.configurations.get(identifier)
|
|
return Promise.resolve(found ? { ...found } : null)
|
|
}),
|
|
findAllEnabled: vi.fn(() => {
|
|
return Promise.resolve(
|
|
[...store.configurations.values()].filter((c) => c.enabled)
|
|
)
|
|
}),
|
|
createIfNotExists: vi.fn((data: Partial<Configuration>) => {
|
|
if (store.configurations.has(data.identifier!)) {
|
|
return Promise.reject(
|
|
new Error(`Configuration with identifier '${data.identifier}' already exists`)
|
|
)
|
|
}
|
|
const now = new Date()
|
|
const config = { ...data, createdAt: now, updatedAt: now } as Configuration
|
|
store.configurations.set(data.identifier!, config)
|
|
return Promise.resolve(config)
|
|
}),
|
|
updateOne: vi.fn((filter: { identifier: string }, update: Partial<Configuration>) => {
|
|
const existing = store.configurations.get(filter.identifier)
|
|
if (existing) {
|
|
const updated = { ...existing, ...update, updatedAt: new Date() }
|
|
store.configurations.set(filter.identifier, updated)
|
|
}
|
|
return Promise.resolve()
|
|
}),
|
|
deleteByIdentifier: vi.fn((identifier: string) => {
|
|
return Promise.resolve(store.configurations.delete(identifier))
|
|
}),
|
|
},
|
|
}))
|
|
|
|
vi.mock('../../app/models/configuration_version.js', () => ({
|
|
default: {
|
|
create: vi.fn((data: Omit<ConfigurationVersion, '_id'>) => {
|
|
const version = { ...data } as ConfigurationVersion
|
|
store.versions.push(version)
|
|
return Promise.resolve(version)
|
|
}),
|
|
getLatestVersionNumber: vi.fn((configurationIdentifier: string) => {
|
|
const versions = store.versions.filter(
|
|
(v) => v.configurationIdentifier === configurationIdentifier
|
|
)
|
|
if (versions.length === 0) return Promise.resolve(0)
|
|
return Promise.resolve(Math.max(...versions.map((v) => v.versionNumber)))
|
|
}),
|
|
findByConfigurationIdentifier: vi.fn((configurationIdentifier: string) => {
|
|
return Promise.resolve(
|
|
store.versions
|
|
.filter((v) => v.configurationIdentifier === configurationIdentifier)
|
|
.sort((a, b) => b.versionNumber - a.versionNumber)
|
|
)
|
|
}),
|
|
deleteByConfigurationIdentifier: vi.fn((configurationIdentifier: string) => {
|
|
const before = store.versions.length
|
|
store.versions = store.versions.filter(
|
|
(v) => v.configurationIdentifier !== configurationIdentifier
|
|
)
|
|
return Promise.resolve(before - store.versions.length)
|
|
}),
|
|
paginateVersions: vi.fn(
|
|
(configurationIdentifier: string, page: number, perPage: number) => {
|
|
const all = store.versions
|
|
.filter((v) => v.configurationIdentifier === configurationIdentifier)
|
|
.sort((a, b) => b.versionNumber - a.versionNumber)
|
|
const total = all.length
|
|
const start = (page - 1) * perPage
|
|
const data = all.slice(start, start + perPage)
|
|
return Promise.resolve({
|
|
data,
|
|
total,
|
|
page,
|
|
perPage,
|
|
lastPage: Math.max(1, Math.ceil(total / perPage)),
|
|
})
|
|
}
|
|
),
|
|
},
|
|
}))
|
|
|
|
// Import the service AFTER mocking
|
|
import ConfigurationService from '../../app/services/configuration_service.js'
|
|
|
|
// --- Arbitraries ---
|
|
|
|
/**
|
|
* Generates a valid Configuration with inventory paths that cover the desired parameters.
|
|
* Configurations are disabled to avoid overlap detection triggering on findAllEnabled.
|
|
*/
|
|
function validConfigurationArb(): fc.Arbitrary<Configuration> {
|
|
return fc
|
|
.record({
|
|
identifier: fc.stringMatching(/^[A-Za-z][A-Za-z0-9]{2,10}$/),
|
|
name: fc.string({ minLength: 1, maxLength: 30 }),
|
|
version: fc.stringMatching(/^[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}$/),
|
|
priority: fc.integer({ min: 1, max: 1000 }),
|
|
policy: fc.constantFrom(PolicyType.ONCE, PolicyType.MAINTAIN),
|
|
enabled: fc.constant(false), // disabled to avoid overlap detection issues
|
|
tags: fc.array(fc.string({ minLength: 1, maxLength: 10 }), { maxLength: 3 }),
|
|
criteria: fc.constant({} as ConfigurationCriteria),
|
|
createdAt: fc.constant(new Date('2024-01-01')),
|
|
updatedAt: fc.constant(new Date('2024-01-01')),
|
|
})
|
|
.map((base) => ({
|
|
...base,
|
|
inventory: [{ path: 'Device.Config.' }] as InventoryPath[],
|
|
desiredParameters: [] as DesiredParameter[],
|
|
firmwareTarget: undefined,
|
|
schedule: undefined,
|
|
})) as fc.Arbitrary<Configuration>
|
|
}
|
|
|
|
function resetStore() {
|
|
store.configurations = new Map()
|
|
store.versions = []
|
|
}
|
|
|
|
describe('Property 15: Version history invariants', () => {
|
|
let service: ConfigurationService
|
|
|
|
beforeEach(() => {
|
|
resetStore()
|
|
service = new ConfigurationService()
|
|
})
|
|
|
|
it('each update creates exactly one new version snapshot', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(validConfigurationArb(), async (config) => {
|
|
// Reset stores for each property run
|
|
resetStore()
|
|
|
|
// Seed the configuration in the store
|
|
store.configurations.set(config.identifier, { ...config })
|
|
|
|
const versionCountBefore = store.versions.length
|
|
|
|
// Perform update
|
|
await service.update(config.identifier, { name: 'updated-name' })
|
|
|
|
const versionCountAfter = store.versions.length
|
|
|
|
// Exactly one version snapshot should have been created
|
|
expect(versionCountAfter - versionCountBefore).toBe(1)
|
|
}),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
|
|
it('version numbers are strictly increasing (monotonic) across multiple updates', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
validConfigurationArb(),
|
|
fc.integer({ min: 2, max: 5 }),
|
|
async (config, updateCount) => {
|
|
// Reset stores
|
|
resetStore()
|
|
|
|
// Seed the configuration
|
|
store.configurations.set(config.identifier, { ...config })
|
|
|
|
// Perform multiple updates
|
|
for (let i = 0; i < updateCount; i++) {
|
|
await service.update(config.identifier, {
|
|
name: `${config.name}-v${i + 1}`,
|
|
})
|
|
}
|
|
|
|
// Get all versions for this configuration
|
|
const versions = store.versions
|
|
.filter((v) => v.configurationIdentifier === config.identifier)
|
|
.sort((a, b) => a.versionNumber - b.versionNumber)
|
|
|
|
// Verify strict monotonic increase
|
|
expect(versions.length).toBe(updateCount)
|
|
for (let i = 1; i < versions.length; i++) {
|
|
expect(versions[i].versionNumber).toBeGreaterThan(versions[i - 1].versionNumber)
|
|
}
|
|
|
|
// Verify version numbers start at 1 and increment by 1
|
|
for (let i = 0; i < versions.length; i++) {
|
|
expect(versions[i].versionNumber).toBe(i + 1)
|
|
}
|
|
}
|
|
),
|
|
{ numRuns: 50 }
|
|
)
|
|
})
|
|
|
|
it('the snapshot contains the state BEFORE the modification', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
validConfigurationArb(),
|
|
fc.string({ minLength: 1, maxLength: 20 }),
|
|
async (config, newName) => {
|
|
// Reset stores
|
|
resetStore()
|
|
|
|
// Seed the configuration with a known original name
|
|
const originalName = config.name
|
|
store.configurations.set(config.identifier, { ...config })
|
|
|
|
// Perform update with a new name
|
|
await service.update(config.identifier, { name: newName })
|
|
|
|
// The snapshot should contain the ORIGINAL name (state before modification)
|
|
const createdVersion = store.versions.find(
|
|
(v) => v.configurationIdentifier === config.identifier
|
|
)
|
|
expect(createdVersion).toBeDefined()
|
|
expect(createdVersion!.snapshot.name).toBe(originalName)
|
|
|
|
// The current configuration should have the NEW name
|
|
const current = store.configurations.get(config.identifier)
|
|
expect(current!.name).toBe(newName)
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
|
|
it('deleting a configuration removes all its versions', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
validConfigurationArb(),
|
|
fc.integer({ min: 1, max: 5 }),
|
|
async (config, updateCount) => {
|
|
// Reset stores
|
|
resetStore()
|
|
|
|
// Seed the configuration
|
|
store.configurations.set(config.identifier, { ...config })
|
|
|
|
// Perform multiple updates to create versions
|
|
for (let i = 0; i < updateCount; i++) {
|
|
await service.update(config.identifier, {
|
|
name: `${config.name}-v${i + 1}`,
|
|
})
|
|
}
|
|
|
|
// Verify versions exist
|
|
const versionsBeforeDelete = store.versions.filter(
|
|
(v) => v.configurationIdentifier === config.identifier
|
|
)
|
|
expect(versionsBeforeDelete.length).toBe(updateCount)
|
|
|
|
// Delete the configuration
|
|
await service.delete(config.identifier)
|
|
|
|
// Verify all versions are removed
|
|
const versionsAfterDelete = store.versions.filter(
|
|
(v) => v.configurationIdentifier === config.identifier
|
|
)
|
|
expect(versionsAfterDelete.length).toBe(0)
|
|
|
|
// Verify the configuration itself is removed
|
|
expect(store.configurations.has(config.identifier)).toBe(false)
|
|
}
|
|
),
|
|
{ numRuns: 50 }
|
|
)
|
|
})
|
|
})
|