Files
adam-acs/tests/unit/services/exchange_store.property.spec.ts
T
Kevin L'Hereec 0c83a4ccb2 feat: TR-069 Exchange Viewer - structured MongoDB storage with SSE streaming and sequence diagram UI
- MongoDB storage: cwmp_exchanges collection with indexes (deviceId+timestamp, deviceId+sequenceNumber unique, TTL 24h)
- ExchangeStoreService: CWMP method extraction, per-device queue for ordering, retry logic, message cap (5000/device)
- Exchange API: GET/DELETE/SSE stream endpoints with Last-Event-ID reconnection
- CwmpLoggerMiddleware: dual-write (MongoDB + legacy files) with fire-and-forget
- Frontend: SequenceViewer (CPE↔ACS lifelines, arrows, auto-scroll, virtual scroll) + InspectorPanel (formatted XML, copy, syntax highlighting)
- Tr069Trace page: integrated new viewer with col-6/col-6 layout
- DeviceDetail page: collapsible exchange section when device is traced
- 14 property-based tests, 40+ unit tests, 5 integration tests
- Health check includes index verification
- Config: feature flags, SSE timeouts, retry policy
2026-06-03 15:52:44 +02:00

435 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import fc from 'fast-check'
// Hoist mocks before any module resolution
const loggerMock = vi.hoisted(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}))
const insertOneMock = vi.hoisted(() => vi.fn())
const deleteOldestBeyondCapMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
const findOneAndUpdateMock = vi.hoisted(() => vi.fn().mockResolvedValue({ seq: 1 }))
vi.mock('@adonisjs/core/services/logger', () => ({
default: loggerMock,
}))
vi.mock('#start/env', () => ({
default: {
get: (key: string, defaultValue?: any) => defaultValue,
},
}))
vi.mock('#models/exchange', () => ({
default: {
getCollection: () => ({
insertOne: insertOneMock,
}),
deleteOldestBeyondCap: deleteOldestBeyondCapMock,
},
}))
vi.mock('#providers/mongodb_provider', () => ({
getMongoDb: () => ({
collection: () => ({
findOneAndUpdate: findOneAndUpdateMock,
}),
}),
}))
vi.mock('#config/exchange_store', () => ({
default: {
maxMessagesPerDevice: 5000,
retry: {
maxAttempts: 3,
baseDelayMs: 1000,
},
featureFlags: {
persistence: true,
sseEnabled: true,
apiSource: 'mongodb',
},
},
}))
import {
ExchangeStoreService,
CRITICAL_METHODS,
NON_CRITICAL_METHODS,
} from '../../../app/services/exchange_store_service.js'
import type { CwmpExchange } from '../../../app/models/exchange.js'
const PBT_NUM_RUNS = 100
/**
* Property 1: CWMP Method Extraction
* Validates: Requirements 1.2, 1.3, 1.6
*
* For any SOAP XML body, the `extractCwmpMethod` function SHALL return the first child element
* name under the `cwmp:` namespace prefix if one exists, "Empty" if the XML is empty/whitespace-only,
* or "Unknown" if no `cwmp:` namespace element is found.
*/
describe('Property 1: CWMP Method Extraction', () => {
let service: ExchangeStoreService
beforeEach(() => {
vi.clearAllMocks()
;(ExchangeStoreService as any).instance = undefined
service = ExchangeStoreService.getInstance()
})
// ─── Arbitraries ─────────────────────────────────────────────────────────────
/**
* Arbitrary that generates valid CWMP method names (alphanumeric word characters).
*/
const cwmpMethodName = fc
.array(
fc.constantFrom(
...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'.split('')
),
{ minLength: 1, maxLength: 40 }
)
.map((chars) => chars.join(''))
/**
* Arbitrary that generates valid SOAP XML with a cwmp: namespace element.
*/
const validCwmpXml = fc
.record({
method: cwmpMethodName,
hasXmlDecl: fc.boolean(),
hasBody: fc.boolean(),
selfClosing: fc.boolean(),
})
.map(({ method, hasXmlDecl, hasBody, selfClosing }) => {
let xml = ''
if (hasXmlDecl) {
xml += '<?xml version="1.0" encoding="UTF-8"?>\n'
}
xml +=
'<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cwmp="urn:dslforum-org:cwmp-1-0">\n'
if (hasBody) {
xml += ' <soap-env:Body>\n'
}
if (selfClosing) {
xml += ` <cwmp:${method}/>\n`
} else {
xml += ` <cwmp:${method}>\n <Data>test</Data>\n </cwmp:${method}>\n`
}
if (hasBody) {
xml += ' </soap-env:Body>\n'
}
xml += '</soap-env:Envelope>'
return { xml, method }
})
/**
* Arbitrary that generates XML without any cwmp: namespace element.
*/
const noCwmpXml = fc
.record({
tagName: fc
.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), {
minLength: 1,
maxLength: 20,
})
.map((chars) => chars.join('')),
content: fc.string({ minLength: 0, maxLength: 50 }).map((s) => s.replace(/[<>&"']/g, 'x')),
})
.map(({ tagName, content }) => {
return `<soap-env:Envelope><soap-env:Body><${tagName}>${content}</${tagName}></soap-env:Body></soap-env:Envelope>`
})
/**
* Arbitrary that generates empty or whitespace-only strings.
*/
const emptyOrWhitespace = fc.constantFrom('', ' ', ' ', '\t', '\n', ' \n\t ', '\r\n')
// ─── Property Tests ──────────────────────────────────────────────────────────
it('returns the cwmp method name for valid SOAP XML with cwmp namespace', () => {
fc.assert(
fc.property(validCwmpXml, ({ xml, method }) => {
const result = service.extractCwmpMethod(xml)
expect(result).toBe(method)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('returns "Empty" for empty or whitespace-only XML', () => {
fc.assert(
fc.property(emptyOrWhitespace, (xml) => {
const result = service.extractCwmpMethod(xml)
expect(result).toBe('Empty')
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('returns "Unknown" for XML without cwmp: namespace', () => {
fc.assert(
fc.property(noCwmpXml, (xml) => {
const result = service.extractCwmpMethod(xml)
expect(result).toBe('Unknown')
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('returns "Unknown" for malformed XML without cwmp namespace', () => {
const malformedXml = fc
.string({ minLength: 1, maxLength: 200 })
.filter((s) => s.trim() !== '' && !s.includes('<cwmp:'))
fc.assert(
fc.property(malformedXml, (xml) => {
const result = service.extractCwmpMethod(xml)
expect(result).toBe('Unknown')
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('extracts the FIRST cwmp method when multiple cwmp elements exist', () => {
const multipleCwmpXml = fc
.record({
method1: cwmpMethodName,
method2: cwmpMethodName,
})
.map(({ method1, method2 }) => ({
xml: `<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Body>
<cwmp:${method1}><Data/></cwmp:${method1}>
<cwmp:${method2}><Data/></cwmp:${method2}>
</soap-env:Body>
</soap-env:Envelope>`,
expectedMethod: method1,
}))
fc.assert(
fc.property(multipleCwmpXml, ({ xml, expectedMethod }) => {
const result = service.extractCwmpMethod(xml)
expect(result).toBe(expectedMethod)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('result is always one of: valid method name, "Empty", or "Unknown"', () => {
fc.assert(
fc.property(fc.string({ minLength: 0, maxLength: 500 }), (xml) => {
const result = service.extractCwmpMethod(xml)
const isValidMethod = /^\w+$/.test(result)
const isSpecialValue = result === 'Empty' || result === 'Unknown'
expect(isValidMethod || isSpecialValue).toBe(true)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('handles cwmp elements with attributes correctly', () => {
const cwmpWithAttributes = fc
.record({
method: cwmpMethodName,
attrName: fc
.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')), {
minLength: 1,
maxLength: 10,
})
.map((chars) => chars.join('')),
attrValue: fc
.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')), {
minLength: 1,
maxLength: 10,
})
.map((chars) => chars.join('')),
})
.map(({ method, attrName, attrValue }) => ({
xml: `<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0"><soap-env:Body><cwmp:${method} ${attrName}="${attrValue}"><Data/></cwmp:${method}></soap-env:Body></soap-env:Envelope>`,
method,
}))
fc.assert(
fc.property(cwmpWithAttributes, ({ xml, method }) => {
const result = service.extractCwmpMethod(xml)
expect(result).toBe(method)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
})
/**
* Property 4: Retry Classification Correctness
* Validates: Requirements 1.7
*
* For any CWMP message where a MongoDB write failure occurs:
* - If the cwmpMethod is in the critical set → exactly 3 retry attempts with exponential backoff SHALL be made
* (1 initial attempt + 3 retries = 4 total insertOne calls)
* - If the cwmpMethod is non-critical (Empty, Unknown) → the message SHALL be discarded immediately with zero retries
* (1 initial attempt only = 1 total insertOne call)
*/
describe('Property 4: Retry Classification Correctness', () => {
let service: ExchangeStoreService
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset singleton for testing
;(ExchangeStoreService as any).instance = undefined
service = ExchangeStoreService.getInstance()
})
afterEach(() => {
vi.useRealTimers()
})
// ─── Arbitraries ─────────────────────────────────────────────────────────────
/**
* Generate a random critical CWMP method from the defined set
*/
const arbCriticalMethod = fc.constantFrom(...Array.from(CRITICAL_METHODS))
/**
* Generate a random non-critical CWMP method from the defined set
*/
const arbNonCriticalMethod = fc.constantFrom(...Array.from(NON_CRITICAL_METHODS))
/**
* Generate a random unknown method name (not in critical or non-critical sets)
* These should behave as non-critical (isCriticalMethod returns false)
*/
const arbUnknownMethod = fc
.string({ minLength: 1, maxLength: 30 })
.filter(
(s) => !CRITICAL_METHODS.has(s) && !NON_CRITICAL_METHODS.has(s) && s.trim().length > 0
)
/**
* Build a CwmpExchange document with a given method
*/
function makeDoc(method: string): CwmpExchange {
return {
deviceId: 'device-test',
direction: 'CPE→ACS',
timestamp: new Date().toISOString(),
sessionId: '10.0.0.1:54321->192.168.1.1:8080',
cwmpMethod: method,
rawXml: `<soap:Envelope><cwmp:${method}/></soap:Envelope>`,
sequenceNumber: 1,
createdAt: new Date(),
}
}
// ─── Property Tests ──────────────────────────────────────────────────────────
it('critical methods → isCriticalMethod returns true and persistWithRetry makes 4 total calls (1 initial + 3 retries)', () => {
return fc.assert(
fc.asyncProperty(arbCriticalMethod, async (method) => {
vi.clearAllMocks()
// Mock insertOne to always fail
insertOneMock.mockRejectedValue(new Error('MongoDB write failed'))
const doc = makeDoc(method)
const isCritical = service.isCriticalMethod(method)
// Verify classification
expect(isCritical).toBe(true)
const promise = service.persistWithRetry(doc, isCritical)
// Advance through all retry delays:
// Initial attempt fails immediately
// Retry 1: after 1000ms (baseDelayMs * 2^0)
// Retry 2: after 2000ms (baseDelayMs * 2^1)
// Retry 3: after 4000ms (baseDelayMs * 2^2)
await vi.advanceTimersByTimeAsync(10000)
await promise
// Critical: 1 initial attempt + 3 retries = 4 total calls
expect(insertOneMock).toHaveBeenCalledTimes(4)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('non-critical methods → isCriticalMethod returns false and persistWithRetry makes exactly 1 call (no retries)', () => {
return fc.assert(
fc.asyncProperty(arbNonCriticalMethod, async (method) => {
vi.clearAllMocks()
// Mock insertOne to always fail
insertOneMock.mockRejectedValue(new Error('MongoDB write failed'))
const doc = makeDoc(method)
const isCritical = service.isCriticalMethod(method)
// Verify classification
expect(isCritical).toBe(false)
await service.persistWithRetry(doc, isCritical)
// Non-critical: 1 initial attempt only, discarded immediately
expect(insertOneMock).toHaveBeenCalledTimes(1)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('unknown methods (not in critical or non-critical sets) → classified as non-critical with 0 retries', () => {
return fc.assert(
fc.asyncProperty(arbUnknownMethod, async (method) => {
vi.clearAllMocks()
// Mock insertOne to always fail
insertOneMock.mockRejectedValue(new Error('MongoDB write failed'))
const doc = makeDoc(method)
const isCritical = service.isCriticalMethod(method)
// Unknown methods are NOT in the critical set
expect(isCritical).toBe(false)
await service.persistWithRetry(doc, isCritical)
// Non-critical: 1 initial attempt only, discarded immediately
expect(insertOneMock).toHaveBeenCalledTimes(1)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('persistWithRetry never throws regardless of method classification (CWMP session never interrupted)', () => {
const arbAnyMethod = fc.oneof(arbCriticalMethod, arbNonCriticalMethod, arbUnknownMethod)
return fc.assert(
fc.asyncProperty(arbAnyMethod, async (method) => {
vi.clearAllMocks()
// Mock insertOne to always fail with any error
insertOneMock.mockRejectedValue(new Error('catastrophic failure'))
const doc = makeDoc(method)
const isCritical = service.isCriticalMethod(method)
const promise = service.persistWithRetry(doc, isCritical)
// Advance timers to allow retries to complete
await vi.advanceTimersByTimeAsync(10000)
// Should NEVER throw
await expect(promise).resolves.toBeUndefined()
}),
{ numRuns: PBT_NUM_RUNS }
)
})
})