- 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>
254 lines
8.7 KiB
TypeScript
254 lines
8.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import fc from 'fast-check'
|
|
import { PolicyType } from '../../app/domain/configuration/configuration.js'
|
|
import type { Configuration } from '../../app/domain/configuration/configuration.js'
|
|
|
|
/**
|
|
* Property 3: Partial updates preserve unmodified fields
|
|
* Validates: Requirements 1.6, 10.3
|
|
*
|
|
* When updating only a subset of fields (toggle enabled, add/remove tags),
|
|
* all other fields remain unchanged. Verified by inspecting the updateOne
|
|
* call payload to ensure only the targeted field is included.
|
|
*/
|
|
|
|
// Mock ConfigurationModel and ConfigurationVersionModel
|
|
vi.mock('../../app/models/configuration.js', () => {
|
|
return {
|
|
default: {
|
|
findByIdentifier: vi.fn(),
|
|
updateOne: vi.fn(),
|
|
findAllEnabled: vi.fn(),
|
|
},
|
|
}
|
|
})
|
|
|
|
vi.mock('../../app/models/configuration_version.js', () => {
|
|
return {
|
|
default: {
|
|
getLatestVersionNumber: vi.fn(),
|
|
create: vi.fn(),
|
|
},
|
|
}
|
|
})
|
|
|
|
import ConfigurationModel from '../../app/models/configuration.js'
|
|
import ConfigurationService from '../../app/services/configuration_service.js'
|
|
|
|
describe('Property 3: Partial updates preserve unmodified fields', () => {
|
|
let service: ConfigurationService
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
service = new ConfigurationService()
|
|
})
|
|
|
|
/**
|
|
* Arbitrary for generating a valid Configuration object.
|
|
*/
|
|
const configurationArb: fc.Arbitrary<Configuration> = fc
|
|
.record({
|
|
identifier: fc
|
|
.string({ minLength: 1, maxLength: 20 })
|
|
.map((s) => s.replace(/[^A-Za-z0-9_-]/g, 'x') || 'cfg'),
|
|
name: fc.string({ minLength: 1, maxLength: 50 }),
|
|
version: fc
|
|
.tuple(fc.nat({ max: 99 }), fc.nat({ max: 99 }), fc.nat({ max: 99 }))
|
|
.map(([a, b, c]) => `${a}.${b}.${c}`),
|
|
priority: fc.integer({ min: 1, max: 1000 }),
|
|
policy: fc.constantFrom(PolicyType.ONCE, PolicyType.MAINTAIN),
|
|
enabled: fc.boolean(),
|
|
tags: fc.array(
|
|
fc.string({ minLength: 1, maxLength: 15 }).map((s) => s.replace(/\s/g, 'a')),
|
|
{ minLength: 0, maxLength: 5 }
|
|
),
|
|
criteria: fc.record({
|
|
manufacturer: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { nil: undefined }),
|
|
oui: fc.option(fc.string({ minLength: 1, maxLength: 10 }), { nil: undefined }),
|
|
productClass: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { nil: undefined }),
|
|
serialNumber: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { nil: undefined }),
|
|
}),
|
|
inventory: fc.array(
|
|
fc.record({
|
|
path: fc
|
|
.array(fc.string({ minLength: 1, maxLength: 10 }).map((s) => s.replace(/\./g, 'x')), {
|
|
minLength: 1,
|
|
maxLength: 4,
|
|
})
|
|
.map((parts) => parts.join('.') + '.'),
|
|
}),
|
|
{ minLength: 0, maxLength: 3 }
|
|
),
|
|
desiredParameters: fc.constant([]),
|
|
firmwareTarget: fc.option(
|
|
fc.record({
|
|
productClass: fc.string({ minLength: 1, maxLength: 20 }),
|
|
targetVersion: fc.string({ minLength: 1, maxLength: 10 }),
|
|
}),
|
|
{ nil: undefined }
|
|
),
|
|
schedule: fc.option(
|
|
fc.record({
|
|
daysOfWeek: fc.array(fc.integer({ min: 0, max: 6 }), { minLength: 1, maxLength: 7 }),
|
|
startHour: fc.integer({ min: 0, max: 23 }),
|
|
endHour: fc.integer({ min: 0, max: 23 }),
|
|
timezone: fc.constantFrom('Europe/Paris', 'America/New_York', 'UTC'),
|
|
}),
|
|
{ nil: undefined }
|
|
),
|
|
createdAt: fc.date(),
|
|
updatedAt: fc.date(),
|
|
})
|
|
.map((rec) => rec as unknown as Configuration)
|
|
|
|
it('toggleEnabled only changes the enabled field, all other fields remain unchanged', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(configurationArb, fc.boolean(), async (existingConfig, newEnabled) => {
|
|
vi.clearAllMocks()
|
|
|
|
// Track what updateOne receives
|
|
let capturedUpdate: Record<string, any> | null = null
|
|
|
|
vi.mocked(ConfigurationModel.findByIdentifier)
|
|
.mockResolvedValueOnce(existingConfig as any) // first call: check existence
|
|
.mockResolvedValueOnce({ ...existingConfig, enabled: newEnabled } as any) // second call: return updated
|
|
|
|
vi.mocked(ConfigurationModel.updateOne).mockImplementation(async (_filter, update) => {
|
|
capturedUpdate = update as Record<string, any>
|
|
return { acknowledged: true, modifiedCount: 1 }
|
|
})
|
|
|
|
await service.toggleEnabled(existingConfig.identifier, newEnabled)
|
|
|
|
// The updateOne call should only contain the enabled field
|
|
expect(capturedUpdate).not.toBeNull()
|
|
expect(capturedUpdate!.enabled).toBe(newEnabled)
|
|
|
|
// Verify no other configuration fields are in the update payload
|
|
const updateKeys = Object.keys(capturedUpdate!)
|
|
const forbiddenKeys = [
|
|
'identifier',
|
|
'name',
|
|
'version',
|
|
'priority',
|
|
'policy',
|
|
'tags',
|
|
'criteria',
|
|
'inventory',
|
|
'desiredParameters',
|
|
'firmwareTarget',
|
|
'schedule',
|
|
'createdAt',
|
|
]
|
|
for (const key of forbiddenKeys) {
|
|
expect(updateKeys).not.toContain(key)
|
|
}
|
|
}),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
|
|
it('updateTags only changes the tags field, all other fields remain unchanged', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
configurationArb,
|
|
fc.array(fc.string({ minLength: 1, maxLength: 10 }).map((s) => s.replace(/\s/g, 'a')), {
|
|
minLength: 0,
|
|
maxLength: 3,
|
|
}),
|
|
fc.array(fc.string({ minLength: 1, maxLength: 10 }).map((s) => s.replace(/\s/g, 'a')), {
|
|
minLength: 0,
|
|
maxLength: 3,
|
|
}),
|
|
async (existingConfig, tagsToAdd, tagsToRemove) => {
|
|
vi.clearAllMocks()
|
|
|
|
let capturedUpdate: Record<string, any> | null = null
|
|
|
|
vi.mocked(ConfigurationModel.findByIdentifier)
|
|
.mockResolvedValueOnce(existingConfig as any) // first call: check existence
|
|
.mockResolvedValueOnce(existingConfig as any) // second call: return after update
|
|
|
|
vi.mocked(ConfigurationModel.updateOne).mockImplementation(async (_filter, update) => {
|
|
capturedUpdate = update as Record<string, any>
|
|
return { acknowledged: true, modifiedCount: 1 }
|
|
})
|
|
|
|
await service.updateTags(existingConfig.identifier, tagsToAdd, tagsToRemove)
|
|
|
|
// The updateOne call should only contain the tags field
|
|
expect(capturedUpdate).not.toBeNull()
|
|
expect(capturedUpdate!).toHaveProperty('tags')
|
|
|
|
// Verify no other configuration fields are in the update payload
|
|
const updateKeys = Object.keys(capturedUpdate!)
|
|
const forbiddenKeys = [
|
|
'identifier',
|
|
'name',
|
|
'version',
|
|
'priority',
|
|
'policy',
|
|
'enabled',
|
|
'criteria',
|
|
'inventory',
|
|
'desiredParameters',
|
|
'firmwareTarget',
|
|
'schedule',
|
|
'createdAt',
|
|
]
|
|
for (const key of forbiddenKeys) {
|
|
expect(updateKeys).not.toContain(key)
|
|
}
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
|
|
it('updateTags preserves existing tags not targeted by remove operations', async () => {
|
|
await fc.assert(
|
|
fc.asyncProperty(
|
|
configurationArb.map((config) => ({
|
|
...config,
|
|
tags: ['existing-tag-1', 'existing-tag-2', 'existing-tag-3'],
|
|
})),
|
|
fc.array(
|
|
fc.string({ minLength: 1, maxLength: 10 }).map((s) => 'new-' + s.replace(/\s/g, '')),
|
|
{ minLength: 1, maxLength: 2 }
|
|
),
|
|
async (existingConfig, tagsToAdd) => {
|
|
vi.clearAllMocks()
|
|
|
|
let capturedUpdate: Record<string, any> | null = null
|
|
|
|
vi.mocked(ConfigurationModel.findByIdentifier)
|
|
.mockResolvedValueOnce(existingConfig as any)
|
|
.mockResolvedValueOnce(existingConfig as any)
|
|
|
|
vi.mocked(ConfigurationModel.updateOne).mockImplementation(async (_filter, update) => {
|
|
capturedUpdate = update as Record<string, any>
|
|
return { acknowledged: true, modifiedCount: 1 }
|
|
})
|
|
|
|
// Only add tags, don't remove any
|
|
await service.updateTags(existingConfig.identifier, tagsToAdd, [])
|
|
|
|
expect(capturedUpdate).not.toBeNull()
|
|
const updatedTags = capturedUpdate!.tags as string[]
|
|
|
|
// All original tags should still be present
|
|
for (const originalTag of existingConfig.tags) {
|
|
expect(updatedTags).toContain(originalTag)
|
|
}
|
|
|
|
// All added tags should be present
|
|
for (const addedTag of tagsToAdd) {
|
|
expect(updatedTags).toContain(addedTag)
|
|
}
|
|
}
|
|
),
|
|
{ numRuns: 100 }
|
|
)
|
|
})
|
|
})
|