- 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
435 lines
14 KiB
TypeScript
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 }
|
|
)
|
|
})
|
|
})
|