Files
adam-acs/tests/unit/components/inspector_panel.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

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 }
)
})
})
})