- 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
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import fc from 'fast-check'
|
|
|
|
// --- Pure logic extracted from InspectorPanel.vue ---
|
|
|
|
/**
|
|
* Formats XML with 2-space indentation (same logic as InspectorPanel.vue prettyPrintXml).
|
|
*/
|
|
function prettyPrintXml(xml: string): string {
|
|
let formatted = ''
|
|
let indent = 0
|
|
const lines = xml
|
|
.replace(/>\s*</g, '>\n<')
|
|
.replace(/\n\s*\n/g, '\n')
|
|
.split('\n')
|
|
|
|
for (const rawLine of lines) {
|
|
const line = rawLine.trim()
|
|
if (!line) continue
|
|
|
|
// Closing tag
|
|
if (line.startsWith('</')) {
|
|
indent = Math.max(0, indent - 1)
|
|
formatted += ' '.repeat(indent * 2) + line + '\n'
|
|
}
|
|
// Self-closing tag or processing instruction
|
|
else if (line.endsWith('/>') || line.startsWith('<?')) {
|
|
formatted += ' '.repeat(indent * 2) + line + '\n'
|
|
}
|
|
// Opening tag (not self-closing)
|
|
else if (line.startsWith('<') && !line.startsWith('</') && line.includes('>')) {
|
|
formatted += ' '.repeat(indent * 2) + line + '\n'
|
|
// Only increase indent if this is a pure opening tag (not self-closing and not containing content + close)
|
|
const tagContent = line.replace(/<[^>]*>/g, '').trim()
|
|
const closeTagMatch = line.match(/<\/[^>]+>/)
|
|
if (!closeTagMatch && !line.endsWith('/>') && tagContent === '') {
|
|
indent++
|
|
}
|
|
} else {
|
|
formatted += ' '.repeat(indent * 2) + line + '\n'
|
|
}
|
|
}
|
|
|
|
return formatted.trim()
|
|
}
|
|
|
|
/**
|
|
* Formats XML content with graceful fallback (same logic as InspectorPanel.vue formatXml).
|
|
*/
|
|
function formatXml(xml: string): string {
|
|
if (!xml || xml.trim() === '') return ''
|
|
|
|
try {
|
|
return prettyPrintXml(xml)
|
|
} catch {
|
|
return xml
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates the header text for the Inspector Panel (same logic as InspectorPanel.vue headerText computed).
|
|
*/
|
|
function generateHeaderText(cwmpMethod: string, direction: string): string {
|
|
return `${cwmpMethod} — ${direction}`
|
|
}
|
|
|
|
// --- Arbitraries ---
|
|
|
|
const PBT_NUM_RUNS = 100
|
|
|
|
const arbDirection = fc.constantFrom('CPE→ACS' as const, 'ACS→CPE' as const)
|
|
|
|
const arbCwmpMethod = fc.constantFrom(
|
|
'Inform',
|
|
'InformResponse',
|
|
'GetParameterValues',
|
|
'GetParameterValuesResponse',
|
|
'SetParameterValues',
|
|
'SetParameterValuesResponse',
|
|
'AddObject',
|
|
'AddObjectResponse',
|
|
'DeleteObject',
|
|
'DeleteObjectResponse',
|
|
'Download',
|
|
'DownloadResponse',
|
|
'Upload',
|
|
'Reboot',
|
|
'FactoryReset',
|
|
'Fault',
|
|
'Empty',
|
|
'Unknown'
|
|
)
|
|
|
|
/**
|
|
* Generate a random XML tag name (valid XML element name: letters, digits, hyphens, dots, underscores, colons).
|
|
*/
|
|
const arbTagName = fc
|
|
.tuple(
|
|
fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz'.split('')),
|
|
fc.array(
|
|
fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-_:.'.split('')),
|
|
{ minLength: 1, maxLength: 15 }
|
|
)
|
|
)
|
|
.map(([first, rest]) => first + rest.join(''))
|
|
|
|
/**
|
|
* Generate safe text content (no XML special chars).
|
|
*/
|
|
const arbSafeText = fc
|
|
.string({ minLength: 1, maxLength: 30 })
|
|
.map((s) => s.replace(/[<>&"']/g, 'x'))
|
|
|
|
/**
|
|
* Generate a simple nested XML structure with 1-3 levels of nesting.
|
|
* Ensures valid well-formed XML that the prettyPrintXml can format.
|
|
*/
|
|
const arbSimpleXml = fc
|
|
.tuple(
|
|
arbTagName,
|
|
fc.array(
|
|
fc.tuple(arbTagName, arbSafeText),
|
|
{ minLength: 1, maxLength: 5 }
|
|
)
|
|
)
|
|
.map(([root, children]) => {
|
|
const childXml = children
|
|
.map(([tag, text]) => `<${tag}>${text}</${tag}>`)
|
|
.join('')
|
|
return `<${root}>${childXml}</${root}>`
|
|
})
|
|
|
|
/**
|
|
* Generate a SOAP envelope-like XML structure with cwmp namespace (realistic test data).
|
|
*/
|
|
const arbSoapXml = fc
|
|
.tuple(
|
|
arbCwmpMethod,
|
|
fc.array(
|
|
fc.tuple(arbTagName, arbSafeText),
|
|
{ minLength: 0, maxLength: 4 }
|
|
)
|
|
)
|
|
.map(([method, params]) => {
|
|
const paramXml = params
|
|
.map(([tag, text]) => `<${tag}>${text}</${tag}>`)
|
|
.join('')
|
|
const body = paramXml
|
|
? `<cwmp:${method}>${paramXml}</cwmp:${method}>`
|
|
: `<cwmp:${method}/>`
|
|
return `<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cwmp="urn:dslforum-org:cwmp-1-0"><soap-env:Header/><soap-env:Body>${body}</soap-env:Body></soap-env:Envelope>`
|
|
})
|
|
|
|
/**
|
|
* Arbitrary for non-empty rawXml content (either simple or SOAP-like).
|
|
*/
|
|
const arbNonEmptyXml = fc.oneof(arbSimpleXml, arbSoapXml)
|
|
|
|
/**
|
|
* Generate a random CwmpExchange object with non-empty rawXml.
|
|
*/
|
|
const arbCwmpExchange = fc.record({
|
|
deviceId: fc
|
|
.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')), {
|
|
minLength: 5,
|
|
maxLength: 20,
|
|
})
|
|
.map((chars) => chars.join('')),
|
|
direction: arbDirection,
|
|
timestamp: fc
|
|
.integer({ min: 1577836800000, max: 1924905600000 })
|
|
.map((ms) => new Date(ms).toISOString()),
|
|
sessionId: fc
|
|
.tuple(
|
|
fc.ipV4(),
|
|
fc.integer({ min: 1024, max: 65535 }),
|
|
fc.ipV4(),
|
|
fc.integer({ min: 1, max: 65535 })
|
|
)
|
|
.map(([ip1, port1, ip2, port2]) => `${ip1}:${port1}->${ip2}:${port2}`),
|
|
cwmpMethod: arbCwmpMethod,
|
|
rawXml: arbNonEmptyXml,
|
|
sequenceNumber: fc.integer({ min: 1, max: 100_000 }),
|
|
})
|
|
|
|
// ─── Property Tests ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Property 12: Inspector Panel Display on Selection
|
|
* Validates: Requirements 5.1, 5.2
|
|
*
|
|
* For any selected CWMP message with non-empty rawXml, the Inspector Panel SHALL
|
|
* display the XML formatted with 2-space indentation AND a header showing the
|
|
* cwmpMethod name and direction ("CPE→ACS" or "ACS→CPE").
|
|
*/
|
|
describe('Property 12: Inspector Panel Display on Selection', () => {
|
|
describe('Formatted XML uses 2-space indentation', () => {
|
|
it('all indented lines in formatted XML start with multiples of 2 spaces', () => {
|
|
fc.assert(
|
|
fc.property(arbCwmpExchange, (exchange) => {
|
|
const formatted = formatXml(exchange.rawXml)
|
|
|
|
// The formatted output should not be empty for non-empty XML
|
|
expect(formatted.length).toBeGreaterThan(0)
|
|
|
|
// Every line should start with 0 or more groups of exactly 2 spaces
|
|
const lines = formatted.split('\n')
|
|
for (const line of lines) {
|
|
if (line.trim() === '') continue
|
|
|
|
// Count leading spaces
|
|
const leadingSpaces = line.match(/^( *)/)?.[1]?.length ?? 0
|
|
|
|
// Leading spaces must be a multiple of 2 (0, 2, 4, 6, ...)
|
|
expect(leadingSpaces % 2).toBe(0)
|
|
}
|
|
}),
|
|
{ numRuns: PBT_NUM_RUNS }
|
|
)
|
|
})
|
|
|
|
it('formatted XML preserves all original tag content (no data loss)', () => {
|
|
fc.assert(
|
|
fc.property(arbCwmpExchange, (exchange) => {
|
|
const formatted = formatXml(exchange.rawXml)
|
|
|
|
// Strip all whitespace from both to compare content
|
|
const originalNoWs = exchange.rawXml.replace(/\s+/g, '')
|
|
const formattedNoWs = formatted.replace(/\s+/g, '')
|
|
|
|
expect(formattedNoWs).toBe(originalNoWs)
|
|
}),
|
|
{ numRuns: PBT_NUM_RUNS }
|
|
)
|
|
})
|
|
|
|
it('nested XML produces increasing indentation levels', () => {
|
|
fc.assert(
|
|
fc.property(arbSoapXml, (xml) => {
|
|
const formatted = formatXml(xml)
|
|
const lines = formatted.split('\n').filter((l) => l.trim() !== '')
|
|
|
|
// For multi-line XML, at least some lines should have indentation > 0
|
|
if (lines.length > 2) {
|
|
const maxIndent = Math.max(
|
|
...lines.map((l) => (l.match(/^( *)/)?.[1]?.length ?? 0))
|
|
)
|
|
// Nested XML should produce at least 2-space indentation somewhere
|
|
expect(maxIndent).toBeGreaterThanOrEqual(2)
|
|
}
|
|
}),
|
|
{ numRuns: PBT_NUM_RUNS }
|
|
)
|
|
})
|
|
|
|
it('self-closing tags do not increase indentation for following lines', () => {
|
|
fc.assert(
|
|
fc.property(arbTagName, arbTagName, (root, child) => {
|
|
// Build XML with a self-closing tag followed by a normal element
|
|
const xml = `<${root}><${child}/><other>text</other></${root}>`
|
|
const formatted = formatXml(xml)
|
|
const lines = formatted.split('\n').filter((l) => l.trim() !== '')
|
|
|
|
// The self-closing tag and the next element should be at the same indentation level
|
|
if (lines.length >= 3) {
|
|
const selfClosingLine = lines.find((l) => l.trim().endsWith('/>'))
|
|
const nextLine = selfClosingLine
|
|
? lines[lines.indexOf(selfClosingLine) + 1]
|
|
: undefined
|
|
|
|
if (selfClosingLine && nextLine && !nextLine.trim().startsWith('</')) {
|
|
const selfClosingIndent = selfClosingLine.match(/^( *)/)?.[1]?.length ?? 0
|
|
const nextIndent = nextLine.match(/^( *)/)?.[1]?.length ?? 0
|
|
expect(nextIndent).toBe(selfClosingIndent)
|
|
}
|
|
}
|
|
}),
|
|
{ numRuns: PBT_NUM_RUNS }
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('Header displays cwmpMethod and direction', () => {
|
|
it('header text contains both cwmpMethod and direction for any exchange', () => {
|
|
fc.assert(
|
|
fc.property(arbCwmpExchange, (exchange) => {
|
|
const header = generateHeaderText(exchange.cwmpMethod, exchange.direction)
|
|
|
|
// Header must include the cwmpMethod
|
|
expect(header).toContain(exchange.cwmpMethod)
|
|
|
|
// Header must include the direction
|
|
expect(header).toContain(exchange.direction)
|
|
}),
|
|
{ numRuns: PBT_NUM_RUNS }
|
|
)
|
|
})
|
|
|
|
it('header text follows format "cwmpMethod — direction"', () => {
|
|
fc.assert(
|
|
fc.property(arbCwmpMethod, arbDirection, (method, direction) => {
|
|
const header = generateHeaderText(method, direction)
|
|
|
|
// Exact format: "method — direction"
|
|
expect(header).toBe(`${method} — ${direction}`)
|
|
}),
|
|
{ numRuns: PBT_NUM_RUNS }
|
|
)
|
|
})
|
|
|
|
it('header always contains the em dash separator', () => {
|
|
fc.assert(
|
|
fc.property(arbCwmpExchange, (exchange) => {
|
|
const header = generateHeaderText(exchange.cwmpMethod, exchange.direction)
|
|
|
|
// The em dash separator must be present
|
|
expect(header).toContain(' — ')
|
|
}),
|
|
{ numRuns: PBT_NUM_RUNS }
|
|
)
|
|
})
|
|
|
|
it('header direction is always one of the two valid values', () => {
|
|
fc.assert(
|
|
fc.property(arbCwmpExchange, (exchange) => {
|
|
const header = generateHeaderText(exchange.cwmpMethod, exchange.direction)
|
|
|
|
// Direction must be one of the two valid directions
|
|
const containsCpeToAcs = header.includes('CPE→ACS')
|
|
const containsAcsToCpe = header.includes('ACS→CPE')
|
|
|
|
expect(containsCpeToAcs || containsAcsToCpe).toBe(true)
|
|
}),
|
|
{ numRuns: PBT_NUM_RUNS }
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('Combined: formatted XML + header for random exchanges', () => {
|
|
it('for any exchange with non-empty rawXml, both formatted output and header are correctly generated', () => {
|
|
fc.assert(
|
|
fc.property(arbCwmpExchange, (exchange) => {
|
|
// --- Verify formatted XML ---
|
|
const formatted = formatXml(exchange.rawXml)
|
|
|
|
// Formatted output is non-empty
|
|
expect(formatted.length).toBeGreaterThan(0)
|
|
|
|
// All lines use 2-space indentation multiples
|
|
const lines = formatted.split('\n')
|
|
for (const line of lines) {
|
|
if (line.trim() === '') continue
|
|
const leadingSpaces = line.match(/^( *)/)?.[1]?.length ?? 0
|
|
expect(leadingSpaces % 2).toBe(0)
|
|
}
|
|
|
|
// --- Verify header ---
|
|
const header = generateHeaderText(exchange.cwmpMethod, exchange.direction)
|
|
|
|
// Header includes cwmpMethod
|
|
expect(header).toContain(exchange.cwmpMethod)
|
|
|
|
// Header includes direction
|
|
expect(header).toContain(exchange.direction)
|
|
}),
|
|
{ numRuns: PBT_NUM_RUNS }
|
|
)
|
|
})
|
|
})
|
|
})
|