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
This commit is contained in:
Kevin L'Hereec committed 2026-06-03 15:52:44 +02:00
1 parent 3fefbf6c40
commit 0c83a4ccb2
50 files changed
+15731 -43

No files matched your search

@@ -0,0 +1 @@
{"specId": "ddeb1b10-5ee4-43c9-893e-b2eba9d9c76f", "workflowType": "requirements-first", "specType": "feature"}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,138 @@
# Requirements Document
## Introduction
Ce document spécifie les exigences pour le système de visualisation interactive des échanges TR-069/CWMP. L'objectif est de remplacer le viewer de log brut actuel par une interface de type diagramme de séquence CPE ↔ ACS, permettant d'inspecter individuellement chaque message SOAP échangé lors d'une session de trace. Le stockage passe d'un format texte brut (fichiers `.log`) à un stockage structuré en MongoDB. L'UI utilise le framework Boosted (Orange CSS basé sur Bootstrap) et s'intègre à la page DeviceDetail existante.
## Glossary
- **Exchange_Store**: Service backend responsable du stockage et de la récupération des échanges CWMP structurés dans MongoDB
- **Exchange_API**: Endpoints REST exposant les échanges structurés d'un device tracé
- **Sequence_Viewer**: Composant frontend Vue 3 affichant le diagramme de séquence CPE ↔ ACS avec les messages cliquables
- **Inspector_Panel**: Panneau latéral affichant le contenu XML SOAP formaté du message sélectionné
- **CWMP_Message**: Un échange SOAP individuel capturé (requête ou réponse) avec ses métadonnées (direction, timestamp, type CWMP, XML brut)
- **Trace_Session**: Période pendant laquelle un device est en mode trace, regroupant tous les échanges capturés
- **CPE**: Customer Premises Equipment — l'équipement réseau du client qui communique avec l'ACS via TR-069
- **ACS**: Auto-Configuration Server — le serveur adam-acs qui gère les CPE via le protocole CWMP
- **Boosted**: Framework CSS Orange basé sur Bootstrap, utilisé pour le design system de l'application
- **SSE**: Server-Sent Events — protocole HTTP unidirectionnel (serveur → client) permettant au backend de pousser des événements en temps réel vers le navigateur via une connexion persistante
## Requirements
### Requirement 1: Stockage structuré des échanges
**User Story:** As a developer, I want CWMP exchanges to be stored in a structured format in MongoDB, so that individual messages can be queried and displayed independently.
#### Acceptance Criteria
1. WHEN CwmpLoggerMiddleware captures a SOAP exchange for a traced device, THE Exchange_Store SHALL persist a CWMP_Message document in MongoDB containing: deviceId, direction (CPE→ACS or ACS→CPE), timestamp (ISO 8601 UTC format), sessionId, cwmpMethod (type de message CWMP extrait du XML), rawXml (limited to 2 MB maximum per message), and a monotonically increasing sequence number per device starting at 1
2. THE Exchange_Store SHALL extract the CWMP method name from the SOAP body element by reading the first child element name under the `cwmp:` namespace prefix (e.g., "Inform", "GetParameterValues", "SetParameterValuesResponse") and store it in the cwmpMethod field
3. IF the SOAP body contains no element with the `cwmp:` namespace prefix (malformed or unrecognized XML), THEN THE Exchange_Store SHALL persist the message with cwmpMethod set to "Unknown" and store the original rawXml unchanged
4. WHILE a device is in trace mode, THE Exchange_Store SHALL persist all exchanges in chronological order, retaining a maximum of 5000 messages per device, and deleting the oldest messages when this limit is exceeded. THE Exchange_Store SHALL NOT persist messages for devices that are not currently in trace mode
5. THE Exchange_Store SHALL create a MongoDB index on (deviceId, timestamp) to support efficient retrieval of exchanges by device
6. IF the XML body is empty (empty POST from CPE), THEN THE Exchange_Store SHALL persist the message with cwmpMethod set to "Empty" and rawXml set to an empty string
7. IF a MongoDB write failure occurs during message persistence, THEN:
- FOR critical messages (RPC Calls, Responses, and Faults such as Inform, GetParameterValues, SetParameterValuesResponse, Fault): THE Exchange_Store SHALL attempt 3 retries with exponential backoff (1s, 2s, 4s) before discarding
- FOR non-critical messages (Empty posts, Unknown method types): THE Exchange_Store SHALL log the error and discard the message immediately
- In both cases, THE Exchange_Store SHALL NOT interrupt the CWMP session processing
### Requirement 2: API de récupération des échanges structurés
**User Story:** As a frontend developer, I want a REST API to retrieve structured CWMP exchanges for a traced device, so that the sequence viewer can display them.
#### Acceptance Criteria
1. WHEN a GET request is made to `/api/v1/tr069-trace/:deviceId/exchanges`, THE Exchange_API SHALL return a JSON array of CWMP_Message objects ordered by timestamp ascending, where each CWMP_Message contains at minimum: timestamp (ISO 8601), direction (CPE→ACS or ACS→CPE), messageType (e.g. Inform, GetParameterValuesResponse), and sessionId
2. WHEN a GET request is made with a `limit` query parameter, THE Exchange_API SHALL cap the number of returned messages to the specified value, defaulting to 200 when omitted, and clamping to a maximum of 1000
3. IF the `limit` query parameter is present but is non-numeric or less than 1, THEN THE Exchange_API SHALL return a 400 status with an error message indicating the valid range (1 to 1000)
4. WHEN a GET request is made with an `after` query parameter containing a valid ISO 8601 timestamp, THE Exchange_API SHALL return only messages with a timestamp strictly greater than the specified value, enabling incremental polling
5. IF the `after` query parameter is present but is not a valid ISO 8601 timestamp, THEN THE Exchange_API SHALL return a 400 status with an error message indicating the expected format
6. IF the device identified by `:deviceId` has never been traced (no historical exchanges exist), THEN THE Exchange_API SHALL return a 404 status with an error message indicating that no trace exists for this device
7. WHEN a GET request is made for a device that is being traced but has no recorded exchanges, THE Exchange_API SHALL return a 200 status with an empty JSON array
8. WHEN a GET request is made for a device that was previously traced but tracing is now stopped, THE Exchange_API SHALL return a 200 status with existing historical exchanges AND include metadata: traceStatus ("ACTIVE" or "STOPPED"), traceStoppedAt (ISO 8601 timestamp if stopped), recordedFrom (timestamp of oldest exchange), and recordedUntil (timestamp of newest exchange)
9. WHEN a DELETE request is made to `/api/v1/tr069-trace/:deviceId/exchanges`, THE Exchange_API SHALL remove all stored exchanges for the device and return a 204 status with no body
### Requirement 3: Intégration dans DeviceDetail
**User Story:** As an operator, I want to access the exchange viewer directly from the device detail page when the device is in trace mode, so that I can inspect protocol exchanges without navigating to a separate page.
#### Acceptance Criteria
1. WHILE a device is in trace mode, THE DeviceDetail page SHALL display a collapsible section containing the Sequence_Viewer, positioned after the Actions section
2. IF the device is not in trace mode, THEN THE DeviceDetail page SHALL display a button allowing the user to enable tracing for the device, within the Actions section. WHILE the device is in trace mode, THE button SHALL remain visible but disabled with a tooltip "Tracing already active. Click 'Disable Tracing' to stop." AND a secondary "Disable Tracing" button SHALL be displayed adjacent to it
3. WHEN the user enables tracing from DeviceDetail, THE DeviceDetail page SHALL display the Sequence_Viewer section showing an empty state message indicating that the system is waiting for the next device connection. The system SHALL allow tracing to be enabled regardless of response time constraints
4. WHILE a device is in trace mode, THE DeviceDetail page SHALL display a session header within the Sequence_Viewer section showing: device identifier, manufacturer, product class, serial number, and trace start time as recorded when tracing was enabled
5. WHEN the Sequence_Viewer section is displayed and no protocol exchanges have been recorded yet, THE DeviceDetail page SHALL display an empty state indicating that no exchanges are available and that data will appear on the next device connection
### Requirement 4: Diagramme de séquence interactif
**User Story:** As an operator, I want to see CWMP exchanges displayed as a sequence diagram with CPE and ACS lifelines, so that I can visually understand the protocol flow.
#### Acceptance Criteria
1. THE Sequence_Viewer SHALL display two vertical lifelines labeled "CPE" and "ACS"
2. THE Sequence_Viewer SHALL render each CWMP_Message as a horizontal arrow between the two lifelines, pointing in the direction of the message (CPE→ACS: left to right, ACS→CPE: right to left), ordered by timestamp ascending
3. THE Sequence_Viewer SHALL display the cwmpMethod name as a label on each arrow, truncated to 40 characters with a tooltip showing the full name if the label exceeds this length
4. THE Sequence_Viewer SHALL display the timestamp of each message in "HH:mm:ss.SSS" format below the method label
5. WHEN the user clicks on a message arrow, THE Sequence_Viewer SHALL apply a visually distinct background color to the selected message row, deselect any previously selected message, and display the selected message's content in the Inspector_Panel
6. THE Sequence_Viewer SHALL use distinct visual styling (color or line style) to differentiate CPE→ACS messages from ACS→CPE messages
7. THE Sequence_Viewer SHALL use Boosted CSS framework classes and Orange design tokens for all styling
8. WHEN no CWMP_Message exists for the current device, THE Sequence_Viewer SHALL display ONLY a placeholder message without rendering the lifelines, to avoid rendering empty UI elements
### Requirement 5: Panneau d'inspection XML
**User Story:** As an operator, I want to see the full formatted XML SOAP content of a selected message, so that I can debug protocol issues.
#### Acceptance Criteria
1. WHEN a message is selected in the Sequence_Viewer, THE Inspector_Panel SHALL display the rawXml content formatted with 2-space indentation and syntax highlighting that visually distinguishes XML elements, attributes, and values using distinct colors
2. WHEN a message is selected in the Sequence_Viewer, THE Inspector_Panel SHALL display a header showing the cwmpMethod name and the message direction expressed as "CPE→ACS" or "ACS→CPE"
3. WHEN the operator activates the "Copy" button, THE Inspector_Panel SHALL copy the raw unformatted XML content to the system clipboard and display a transient confirmation indicator visible for at least 2 seconds
4. THE Inspector_Panel SHALL be displayed as a resizable side panel on the right side of the Sequence_Viewer with a minimum width of 300px and a maximum width of 70% of the viewport width
5. WHEN no message is selected, THE Inspector_Panel SHALL display a placeholder text indicating the user should select a message to inspect
6. IF the rawXml content of the selected message is empty or cannot be parsed, THEN THE Inspector_Panel SHALL display a message indicating that no XML content is available for the selected message
### Requirement 6: Mise à jour en temps réel via SSE
**User Story:** As an operator, I want to see new exchanges appearing in the sequence diagram in real-time as they happen, so that I can monitor live protocol activity without delay.
#### Acceptance Criteria
1. THE Exchange_API SHALL expose a SSE endpoint at `GET /api/v1/tr069-trace/:deviceId/exchanges/stream` that opens a persistent HTTP connection with `Content-Type: text/event-stream` and pushes each new CWMP_Message as a JSON-encoded SSE `data` event as soon as it is persisted by the Exchange_Store
2. WHEN the Sequence_Viewer is opened for a traced device, THE Sequence_Viewer SHALL establish an EventSource connection to the SSE endpoint and listen for incoming messages
3. WHEN a new SSE event is received, THE Sequence_Viewer SHALL append the CWMP_Message to the bottom of the diagram while preserving the current scroll position and the currently selected message (if any)
4. THE Sequence_Viewer SHALL display a visual indicator distinguishing between three connection states: "connected" (SSE stream open and receiving), "paused" (auto-scroll paused but still connected), and "disconnected" (connection lost)
5. WHEN the user's scroll position is not at the bottom of the diagram AND the SSE connection is active, THE Sequence_Viewer SHALL pause auto-scroll but continue receiving SSE events in the background
6. WHILE auto-scroll is paused, THE Sequence_Viewer SHALL display a visible button allowing the user to resume auto-scroll, which scrolls the diagram to the latest message and re-enables auto-scroll
7. IF the SSE connection is lost (network error, server restart, or timeout), THEN THE Sequence_Viewer SHALL update the visual indicator to "disconnected" state, preserve all previously displayed messages, and attempt automatic reconnection using the EventSource built-in reconnection mechanism with a retry interval of 3 seconds
8. WHEN the SSE connection is re-established after a disconnection, THE Sequence_Viewer SHALL send the `Last-Event-ID` header (set to the sequence number of the last received message) so that the server resumes from where the client left off without duplicating messages
9. THE SSE endpoint SHALL assign a unique `id` field to each event (using the CWMP_Message sequence number) to support the `Last-Event-ID` reconnection mechanism
10. WHEN the Sequence_Viewer is closed or the user navigates away from the DeviceDetail page, THE Sequence_Viewer SHALL close the EventSource connection to free server resources. THE SSE endpoint SHALL also implement server-side connection timeouts: inactivity timeout of 5 minutes (no new exchanges pushed) and absolute timeout of 2 hours, to free resources for abandoned connections. WHEN a server-side timeout occurs, THE server SHALL close the connection gracefully, triggering client-side reconnection
11. IF the device is not currently being traced, THEN THE SSE endpoint SHALL return a 404 status and close the connection immediately
### Requirement 7: Performance et pagination
**User Story:** As an operator working with devices that generate hundreds of exchanges, I want the viewer to remain responsive, so that I can navigate through large sessions without lag.
#### Acceptance Criteria
1. THE Sequence_Viewer SHALL implement virtual scrolling or pagination to handle sessions with more than 200 messages while maintaining a frame render time of 100 milliseconds or less
2. IF the session contains more than 200 messages, THEN THE Sequence_Viewer SHALL load messages in batches of 50, display the most recent batch first, and provide scroll-based or button-based navigation to access older messages
3. THE Exchange_Store SHALL support a TTL (time-to-live) mechanism that automatically removes exchanges older than 24 hours to prevent unbounded storage growth
4. IF an exchange retrieval request elapsed time exceeds 5 seconds, THEN THE Exchange_API SHALL return a partial result containing the most recent messages in reverse-chronological order, along with an `isTruncated: true` flag and the total number of available messages
### Requirement 8: Purge et gestion des échanges
**User Story:** As an operator, I want to clear the exchange history for a device, so that I can start a fresh observation session.
#### Acceptance Criteria
1. WHILE a traced device is selected in the log viewer, THE Sequence_Viewer SHALL display a "Clear exchanges" button that deletes all stored server-side exchange logs for that device
2. WHEN the user clicks the "Clear exchanges" button, THE Sequence_Viewer SHALL display a confirmation dialog requesting explicit acceptance before proceeding with deletion
3. IF the user cancels the confirmation dialog, THEN THE Sequence_Viewer SHALL close the dialog and leave the existing exchange log unchanged
4. WHEN the user confirms the deletion AND the server returns a successful response (204), THE Sequence_Viewer SHALL:
- Verify the SSE connection remains active
- Reset the log panel to an empty state displaying a "No log entries yet" message
- Continue receiving new messages via the active SSE connection
- IF SSE becomes inactive during or after the clear operation, attempt automatic reconnection before resuming
5. IF the server fails to clear the exchanges, THEN THE Sequence_Viewer SHALL display an error message indicating the failure and preserve the current exchange log content
+531
View File
@@ -0,0 +1,531 @@
# Implementation Plan: TR-069 Exchange Viewer
## Overview
This implementation plan converts the TR-069 Exchange Viewer design into incremental coding tasks. The feature replaces the file-based CWMP trace system with structured MongoDB storage, real-time SSE streaming, and an interactive sequence diagram UI. Tasks follow the phased approach (MVP → Robustness → Optimization) and build on each other progressively.
## Tasks
- [x] 0. Database Setup
- [x] 0.1 Create MongoDB migration for cwmp_exchanges collection
- Create migration file `database/migrations/XXXX_create_cwmp_exchanges.ts`
- Create `cwmp_exchanges` collection and `cwmp_exchange_counters` collection
- Create all 3 indexes: compound (deviceId, timestamp), unique (deviceId, sequenceNumber), TTL (createdAt, 86400s)
- Implement rollback logic (`down()` method) to drop both collections
- _Requirements: 1.1, 1.5, 7.3_
- [x] 0.2 Create database seeding script for testing
- Create `database/seeders/cwmp_exchanges_seeder.ts`
- Generate 100-500 sample exchanges with realistic CWMP methods (Inform, GetParameterValues, SetParameterValues, etc.)
- Include varied directions, sessionIds, timestamps spanning 24h, and realistic XML bodies
- Support configurable device count and exchange count per device
- _For integration/manual testing_
- [x] 0.3 Verify MongoDB indexes after app startup
- Add health check logic in app startup to verify cwmp_exchanges indexes exist
- Log warnings if any expected index is missing
- Include index verification in the health check endpoint response
- _Requirements: 1.5_
- [x] 1. Set up configuration and data model foundations
- [x] 1.1 Create Exchange Store configuration file
- Create `config/exchange_store.ts` with all configurable values (maxMessagesPerDevice, TTL, SSE timeouts, retry policy, feature flags)
- Use `env.get()` pattern consistent with existing AdonisJS configs
- _Requirements: 1.4, 1.7, 6.10, 7.3_
- [x] 1.2 Create Exchange MongoDB model
- Create `app/models/exchange.ts` with the `CwmpExchange` interface and `ExchangeModel` class
- Implement static methods: `findByDevice`, `findAfterSequence`, `deleteByDevice`, `getMaxSequenceNumber`, `countByDevice`, `deleteOldestBeyondCap`, `hasExchanges`, `getMetadata`
- Implement `ensureIndexes()` for (deviceId, timestamp), (deviceId, sequenceNumber) unique, and TTL on createdAt
- Follow existing model patterns from `app/models/device.ts`
- _Requirements: 1.1, 1.5, 7.3_
- [x] 2. Implement ExchangeStoreService (core backend logic)
- [x] 2.1 Create ExchangeStoreService with CWMP method extraction and persistence
- Create `app/services/exchange_store_service.ts` as a singleton service
- Implement `extractCwmpMethod(xml)`: parse first `cwmp:` namespace child element, return "Empty" for empty/whitespace, "Unknown" for no cwmp element
- Implement `persistExchange(options)`: extract method, get sequence number, persist to MongoDB, emit event via EventEmitter
- Implement `getNextSequenceNumber(deviceId)`: atomic increment via `cwmp_exchange_counters` collection using `findOneAndUpdate` with upsert
- Implement `subscribe(deviceId, listener)` and `unsubscribe(deviceId, listener)` for EventEmitter-based pub/sub
- _Requirements: 1.1, 1.2, 1.3, 1.6_
- [x] 2.2 Write property test: CWMP Method Extraction (Property 1)
- **Property 1: CWMP Method Extraction**
- Test with random SOAP XML bodies: valid cwmp namespace → extract method name, empty XML → "Empty", no cwmp namespace → "Unknown"
- **Validates: Requirements 1.2, 1.3, 1.6**
- **Acceptance Criteria:**
- ✅ 100+ iterations with fast-check
- ✅ All edge cases covered (empty, malformed, valid cwmp namespace, multiple namespaces)
- ✅ Test file: `tests/unit/services/exchange_store.property.spec.ts`
- [x] 2.3 Write property test: Sequence Number Monotonicity (Property 2)
- **Property 2: Sequence Number Monotonicity**
- Test with random series of insertions for a device: sequence numbers are strictly monotonically increasing (each = previous + 1)
- **Validates: Requirements 1.1**
- **Acceptance Criteria:**
- ✅ 100+ iterations with fast-check
- ✅ All edge cases covered (concurrent insertions, multiple devices, reset scenarios)
- ✅ Test file: `tests/unit/services/exchange_store.property.spec.ts`
- [x] 2.4 Implement message cap enforcement
- Implement `enforceMessageCap(deviceId)`: delete oldest messages when count exceeds 5000
- Call cap enforcement after each persist
- _Requirements: 1.4_
- [x] 2.5 Write property test: Message Cap Enforcement (Property 3)
- **Property 3: Message Cap Enforcement**
- Test with random number of insertions: count never exceeds 5000, retained messages are the most recent ones
- **Validates: Requirements 1.4**
- **Acceptance Criteria:**
- ✅ 100+ iterations with fast-check
- ✅ All edge cases covered (exact boundary 5000, 5001, bulk inserts)
- ✅ Test file: `tests/unit/services/exchange_store.property.spec.ts`
- [x] 2.6 Implement retry logic for critical messages
- Implement `persistWithRetry(doc, isCritical)`: classify message by cwmpMethod (critical vs non-critical), apply 3 retries with exponential backoff (1s, 2s, 4s) for critical, discard immediately for non-critical
- Ensure CWMP session is never interrupted regardless of persistence outcome
- _Requirements: 1.7_
- [x] 2.7 Write property test: Retry Classification Correctness (Property 4)
- **Property 4: Retry Classification Correctness**
- Test with random CWMP methods: critical methods → 3 retries with backoff, non-critical → 0 retries
- **Validates: Requirements 1.7**
- **Acceptance Criteria:**
- ✅ 100+ iterations with fast-check
- ✅ All edge cases covered (all critical methods, all non-critical methods, unknown methods)
- ✅ Test file: `tests/unit/services/exchange_store.property.spec.ts`
- [x] 3. Checkpoint - Core service verified
- Ensure all tests pass, ask the user if questions arise.
- **Success Criteria:**
- ✅ All 4 property tests pass (100+ iterations each)
- ✅ Unit test coverage >85% for ExchangeStoreService
- ✅ Manual verification: sequence numbers increment correctly
- ✅ Manual verification: message cap enforcement at 5000
- ✅ Manual verification: retry logic with simulated MongoDB failure
- [x] 4. Implement Exchange API Controller
- [x] 4.1 Create Exchange Controller with GET and DELETE endpoints
- Create `app/controllers/exchange_controller.ts`
- Implement `index()`: GET exchanges with `limit` (default 200, max 1000) and `after` query params, validate inputs, return ordered by timestamp ascending, include metadata (traceStatus, recordedFrom, recordedUntil, total)
- Implement `destroy()`: DELETE all exchanges for a device, return 204
- Handle edge cases: 404 for never-traced device, 200 empty array for traced with no messages, 400 for invalid params
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9_
- [x] 4.2 Write property test: API Result Ordering (Property 5)
- **Property 5: API Result Ordering**
- Test with random sets of persisted exchanges: returned array ordered by timestamp ascending with no inversions
- **Validates: Requirements 2.1**
- **Acceptance Criteria:**
- ✅ 100+ iterations with fast-check
- ✅ All edge cases covered (empty set, single item, duplicates, boundary timestamps)
- ✅ Test file: `tests/unit/controllers/exchange_controller.property.spec.ts`
- [x] 4.3 Write property test: Limit Parameter Validation (Property 6)
- **Property 6: Limit Parameter Validation and Capping**
- Test with random limit values: [1,1000] → at most `limit` messages, omitted → 200, non-numeric or <1 → 400
- **Validates: Requirements 2.2, 2.3**
- **Acceptance Criteria:**
- ✅ 100+ iterations with fast-check
- ✅ All edge cases covered (0, -1, 1001, NaN, float, string)
- ✅ Test file: `tests/unit/controllers/exchange_controller.property.spec.ts`
- [x] 4.4 Write property test: After Parameter Filtering (Property 7)
- **Property 7: After Parameter Filtering**
- Test with random ISO 8601 timestamps: all returned exchanges have timestamp > specified value, invalid timestamp → 400
- **Validates: Requirements 2.4, 2.5**
- **Acceptance Criteria:**
- ✅ 100+ iterations with fast-check
- ✅ All edge cases covered (future dates, epoch, invalid strings, partial ISO)
- ✅ Test file: `tests/unit/controllers/exchange_controller.property.spec.ts`
- [x] 4.5 Implement SSE stream endpoint
- Implement `stream()`: SSE endpoint at GET `/api/v1/tr069-trace/:deviceId/exchanges/stream`
- Set SSE headers (Content-Type, Cache-Control, Connection, X-Accel-Buffering)
- Handle Last-Event-ID for reconnection: query exchanges with sequenceNumber > lastEventId
- Subscribe to ExchangeStoreService events, push new exchanges as SSE events with `id: sequenceNumber`
- Implement server-side timeouts: 5min inactivity, 2h absolute
- Return 404 if device is not traced
- _Requirements: 6.1, 6.8, 6.9, 6.10, 6.11_
- [x] 4.6 Write property test: SSE Event ID Consistency (Property 13)
- **Property 13: SSE Event ID Consistency**
- Test with random exchanges: event `id` field always equals the exchange's `sequenceNumber`
- **Validates: Requirements 6.9**
- **Acceptance Criteria:**
- ✅ 100+ iterations with fast-check
- ✅ All edge cases covered (first message, large sequence numbers, gaps)
- ✅ Test file: `tests/unit/controllers/exchange_controller.property.spec.ts`
- [x] 4.7 Write property test: SSE Reconnection Continuity (Property 14)
- **Property 14: SSE Reconnection Continuity (Last-Event-ID)**
- Test with random disconnect points: reconnection with Last-Event-ID=N resumes only exchanges with seq > N, zero duplicates
- **Validates: Requirements 6.8**
- **Acceptance Criteria:**
- ✅ 100+ iterations with fast-check
- ✅ All edge cases covered (Last-Event-ID=0, very large ID, non-existent ID)
- ✅ Test file: `tests/unit/controllers/exchange_controller.property.spec.ts`
- [x] 5. Modify existing code for integration
- [x] 5.1 Modify CwmpLoggerMiddleware for dual-write
- Update `app/middleware/cwmp_logger_middleware.ts` to call `ExchangeStoreService.persistExchange()` in addition to existing file-based writes (dual-write for Phase 1 safety)
- Remove prettyXml formatting for the new system (store raw XML)
- Keep brief app-level logging for non-traced devices
- Ensure middleware never blocks CWMP processing (fire-and-forget with error handling)
- _Requirements: 1.1, 1.7_
- [x] 5.2 Register Exchange API routes
- Add routes to `start/routes.ts` within the `/api/v1` group:
- `GET /tr069-trace/:deviceId/exchanges` → ExchangeController.index
- `DELETE /tr069-trace/:deviceId/exchanges` → ExchangeController.destroy
- `GET /tr069-trace/:deviceId/exchanges/stream` → ExchangeController.stream
- _Requirements: 2.1, 2.9, 6.1_
- [x] 6. Checkpoint - Backend complete
- Ensure all tests pass, ask the user if questions arise.
- **Success Criteria:**
- ✅ All API property tests pass (Properties 5, 6, 7, 13, 14)
- ✅ Unit test coverage >85% for ExchangeController
- ✅ Manual verification: GET /exchanges returns ordered results
- ✅ Manual verification: DELETE /exchanges returns 204
- ✅ Manual verification: SSE stream connects and pushes events
- ✅ Manual verification: Last-Event-ID reconnection works correctly
- [x] 7. Integration Testing
- [x] 7.1 Full exchange lifecycle integration test
- Test complete flow: SOAP capture → persist to MongoDB → SSE event emitted → frontend receives
- Verify data integrity at each step (deviceId, direction, cwmpMethod, rawXml preserved)
- Test file: `tests/integration/exchange_lifecycle.spec.ts`
- _Validates: Requirements 1.1, 1.7, 6.1, 6.2_
- [x] 7.2 SSE reconnection with Last-Event-ID integration test
- Connect to SSE stream, receive 5 messages, simulate disconnect
- Reconnect with Last-Event-ID set to sequence number of last received message
- Verify: no duplicates received, messages resume from correct point
- Test file: `tests/integration/sse_reconnection.spec.ts`
- _Validates: Requirements 6.8, 6.9_
- [x] 7.3 Message cap enforcement integration test
- Insert 5001 messages for a single device
- Verify: oldest message deleted, total count = 5000
- Verify: retained messages are the most recent by timestamp
- Test file: `tests/integration/message_cap.spec.ts`
- _Validates: Requirements 1.4_
- [x] 7.4 TTL expiration integration test
- Insert exchanges with `createdAt` set to 25 hours ago
- Verify: MongoDB TTL index auto-removes expired documents
- Note: requires waiting for MongoDB TTL monitor (runs every 60s) or use test helper
- Test file: `tests/integration/ttl_expiration.spec.ts`
- _Validates: Requirements 7.3_
- [x] 7.5 Clear exchanges flow integration test
- Create exchanges for a device, verify they exist
- Call DELETE endpoint, verify 204 response
- Verify: exchange list is empty
- Verify: SSE stream remains active and continues receiving new messages
- Test file: `tests/integration/clear_exchanges.spec.ts`
- _Validates: Requirements 8.1, 8.2, 8.3, 8.4, 8.5_
- [x] 8. Implement frontend composable and components
- [x] 8.1 Create useExchangeStream composable
- Create `inertia/composables/useExchangeStream.ts`
- Implement EventSource connection management (connect, disconnect)
- Manage reactive state: messages array, connectionState ('connected'|'paused'|'disconnected'), isAutoScrollEnabled
- Handle SSE events: parse JSON, append to messages, manage auto-scroll
- Handle reconnection: EventSource built-in retry (3s), preserve Last-Event-ID
- Implement clearMessages: call DELETE API, reset state
- Implement fallback to REST polling (5s interval) if SSE unavailable
- _Requirements: 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 8.4_
- [x] 8.2 Create SequenceViewer.vue component
- Create `inertia/components/SequenceViewer.vue`
- Render CPE and ACS vertical lifelines
- Render message arrows: left-to-right for CPE→ACS, right-to-left for ACS→CPE
- Display cwmpMethod label (truncated at 40 chars with tooltip) and timestamp (HH:mm:ss.SSS)
- Handle message selection (highlight row, emit to Inspector Panel)
- Display connection state indicator (connected/paused/disconnected)
- Display "Resume auto-scroll" button when paused
- Display session header (device identifier, manufacturer, productClass, serialNumber, trace start time)
- Display empty state placeholder when no messages (without lifelines)
- Display "Clear exchanges" button with confirmation dialog
- Use Boosted CSS framework classes and Orange design tokens
- _Requirements: 3.1, 3.4, 3.5, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 6.3, 6.4, 6.5, 6.6, 8.1, 8.2, 8.3, 8.4, 8.5_
- [x] 8.3 Write property test: Arrow Direction Correctness (Property 9)
- **Property 9: Arrow Direction Correctness**
- Test with random lists of messages with mixed directions: CPE→ACS arrows point left-to-right, ACS→CPE point right-to-left, ordered by timestamp ascending
- **Validates: Requirements 4.2**
- **Acceptance Criteria:**
- ✅ 100+ iterations with fast-check
- ✅ All edge cases covered (all same direction, alternating, single message)
- ✅ Test file: `tests/unit/components/sequence_viewer.property.spec.ts`
- [x] 8.4 Write property test: Method Label Truncation (Property 10)
- **Property 10: Method Label Truncation**
- Test with random method names: >40 chars → truncated + tooltip, ≤40 chars → full name displayed
- **Validates: Requirements 4.3**
- **Acceptance Criteria:**
- ✅ 100+ iterations with fast-check
- ✅ All edge cases covered (exactly 40 chars, 41 chars, empty string, unicode)
- ✅ Test file: `tests/unit/components/sequence_viewer.property.spec.ts`
- [x] 8.5 Write property test: Timestamp Formatting (Property 11)
- **Property 11: Timestamp Formatting**
- Test with random valid ISO 8601 timestamps: formatted display matches `HH:mm:ss.SSS` pattern
- **Validates: Requirements 4.4**
- **Acceptance Criteria:**
- ✅ 100+ iterations with fast-check
- ✅ All edge cases covered (midnight, noon, millisecond precision, timezone offsets)
- ✅ Test file: `tests/unit/components/sequence_viewer.property.spec.ts`
- [x] 8.6 Create InspectorPanel.vue component
- Create `inertia/components/InspectorPanel.vue`
- Display formatted XML with 2-space indentation and syntax highlighting (elements, attributes, values in distinct colors)
- Display header showing cwmpMethod + direction
- Implement "Copy" button: copy raw XML to clipboard, show confirmation for 2s
- Implement resizable panel (min 300px, max 70% viewport)
- Display placeholder when no message selected
- Handle empty/unparseable XML gracefully
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_
- [x] 8.7 Write property test: Inspector Panel Display (Property 12)
- **Property 12: Inspector Panel Display on Selection**
- Test with random messages with non-empty rawXml: panel displays formatted XML with 2-space indentation AND header with cwmpMethod + direction
- **Validates: Requirements 5.1, 5.2**
- **Acceptance Criteria:**
- ✅ 100+ iterations with fast-check
- ✅ All edge cases covered (empty XML, malformed XML, large XML, special characters)
- ✅ Test file: `tests/unit/components/inspector_panel.property.spec.ts`
- [x] 9. Integrate into DeviceDetail page
- [x] 9.1 Modify DeviceDetail.vue to add Sequence Viewer section
- Add collapsible "Échanges TR-069" section to `inertia/pages/DeviceDetail.vue`, positioned after the Actions section
- Show section only when device is traced, with SequenceViewer + InspectorPanel
- Show enable/disable tracing button in Actions section
- Pass device props (identifier, manufacturer, productClass, serialNumber) and traceStartedAt to SequenceViewer
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
- [x] 10. Checkpoint - MVP complete (Phase 1)
- Ensure all tests pass, ask the user if questions arise.
- **Success Criteria:**
- ✅ Component property tests pass (Properties 9, 10, 11, 12)
- ✅ SequenceViewer renders correctly with sample data
- ✅ Full integration test passes (SOAP → MongoDB → SSE → UI)
- ✅ Manual real-time test: trace device, trigger Inform, see exchange appear live
- [x] 11. Phase 2 — Robustness enhancements
- [x] 11.1 Implement virtual scrolling for large sessions
- Add virtual scrolling to SequenceViewer.vue for sessions >200 messages
- Use fixed item height (60px) with buffer of 5 items
- Maintain frame render time ≤100ms
- _Requirements: 7.1_
- [x] 11.2 Implement lazy loading in batches of 50
- Load messages in batches of 50 using IntersectionObserver
- Display most recent batch first, load older on scroll
- _Requirements: 7.2_
- [x] 11.3 Implement memory management (max 1000 cached messages)
- Cap in-memory message cache to 1000 most recent messages
- Discard oldest messages when cap is exceeded on client side
- _Requirements: 7.1_
- [x] 11.4 Add read timeout handling (5s partial results)
- In Exchange Controller index(), return partial results with `isTruncated: true` flag if query exceeds 5 seconds
- Include total count in response for client awareness
- _Requirements: 7.4_
- [x] 12. Checkpoint - Robustness complete
- Ensure all tests pass, ask the user if questions arise.
- **Success Criteria:**
- ✅ Virtual scroll maintains 60FPS at 5000 messages
- ✅ Lazy loading works with IntersectionObserver
- ✅ Memory cap enforced at 1000 cached messages client-side
- ✅ Read timeout returns partial results correctly
- [ ] 13. Documentation
- [ ] 13.1 Write API documentation (OpenAPI/Swagger)
- Document all 3 endpoints: GET /exchanges, DELETE /exchanges, GET /exchanges/stream
- Include request/response examples for each endpoint
- Document all error codes (400, 404, 204) with descriptions
- Document query parameters (limit, after) with valid ranges
- Document SSE event format and reconnection protocol
- File: `docs/api/tr069-exchanges.yaml` or inline AdonisJS Swagger annotations
- [ ] 13.2 Write deployment guide
- Document MongoDB setup requirements (collection, indexes, replica set not required)
- Document environment variables (FEATURE_EXCHANGE_PERSISTENCE, FEATURE_SSE_ENABLED, FEATURE_EXCHANGE_API_SOURCE)
- Document feature flag usage and toggle procedures
- Document health check endpoint and monitoring
- File: `docs/deployment/tr069-exchange-viewer.md`
- [ ] 13.3 Write troubleshooting guide
- Document common issues: SSE disconnections (causes, solutions)
- Document MongoDB timeout scenarios and recovery
- Document message loss diagnosis (check logs, verify retry, check cap)
- Document how to verify indexes and TTL status
- File: `docs/troubleshooting/tr069-exchange-viewer.md`
- [x] 13.4 Write migration guide
- Document data migration script from file-based to MongoDB
- Document dual-write phase (Phase 1) and how to verify consistency
- Document cutover procedure (Phase 2 → Phase 3)
- Document rollback procedure at each phase
- File: `docs/migration/tr069-exchange-viewer.md`
- [ ] 14. Monitoring & Observability
- [ ] 14.1 Implement health check endpoint
- Create `GET /api/v1/health/tr069-exchanges` endpoint
- Check MongoDB connectivity and latency
- Report active SSE connection count
- Report collection stats (document count, size)
- Return structured JSON response with status healthy/degraded/unhealthy
- _Requirements: 1.5_
- [ ] 14.2 Add structured logging
- Log all persistence operations (success/failure) with deviceId, cwmpMethod, duration
- Log SSE connection events (connect, disconnect, reconnect) with deviceId
- Log API requests with method, path, status code, response time
- Use JSON format compatible with ELK/Datadog ingestion
- Include correlation IDs for tracing across components
- [ ] 14.3 Implement performance metrics
- Track MongoDB write latency: p50, p95, p99
- Track SSE push latency (time from persist to client receive)
- Track API response times by endpoint
- Expose metrics in Prometheus format at `/metrics` or via structured logs
- [ ] 14.4 Configure alerting rules
- Alert: SSE active connections > 100 (high load)
- Alert: MongoDB write latency p95 > 100ms (performance degradation)
- Alert: Message loss rate > 0.1% (critical failures)
- Alert: TTL cleanup lag > 1 hour (index issue)
- Document thresholds and escalation procedures
- [ ] 15. Final checkpoint - All phases complete
- Ensure all tests pass, ask the user if questions arise.
- **Success Criteria:**
- ✅ Virtual scroll 60FPS at 5000 messages
- ✅ Lazy loading works correctly
- ✅ Memory cap enforced client-side
- ✅ Documentation complete (API, deployment, troubleshooting, migration)
- ✅ Monitoring configured (health check, logging, metrics, alerts)
- ✅ All integration tests pass
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- Each task references specific requirements for traceability
- Checkpoints ensure incremental validation
- Property tests validate universal correctness properties from the design document
- Unit tests validate specific examples and edge cases
- Phase 1 (tasks 0-10) delivers a working MVP with dual-write safety
- Phase 2 (tasks 11-12) adds robustness for production load
- Phase 3 (tasks 13-14) adds documentation and observability
- The dual-write approach in task 5.1 ensures rollback safety: old file-based system continues until new system is validated
- All frontend components use Boosted (Orange CSS) framework — no additional CSS framework needed
## Testing Strategy
### Test Pyramid
| Level | Count | Description |
|-------|-------|-------------|
| Property Tests | 14 | Universal correctness properties (fast-check, 100+ iterations) |
| Unit Tests | 40+ | Example-based tests for specific behaviors and edge cases |
| Integration Tests | 6 | End-to-end flow validation (lifecycle, SSE, cap, TTL, clear, reconnection) |
### Test Coverage Goals
| Type | Target | Rationale |
|------|--------|-----------|
| Unit Tests | 85%+ | Core services and controllers must be well-covered |
| Integration Tests | 80%+ | Critical flows must be validated end-to-end |
| E2E Tests | 60%+ (future) | UI flows with real browser — deferred to post-MVP |
### Running Tests
```bash
# Run all tests
npm run test
# Run unit tests only
npm run test:unit
# Run integration tests only
npm run test:integration
# Run tests in watch mode (development)
npm run test:watch
# Run with coverage report
npm run test:coverage
```
## Rollback & Contingency Plan
### Phase 1 Rollback (Persistence & SSE)
| Feature Flag | Effect |
|--------------|--------|
| `FEATURE_EXCHANGE_PERSISTENCE=false` | Disables MongoDB writes; CWMP sessions continue unaffected |
| `FEATURE_SSE_ENABLED=false` | Disables SSE streaming; frontend falls back to REST polling (5s) |
### Phase 2 Rollback (API Source)
| Feature Flag | Effect |
|--------------|--------|
| `FEATURE_EXCHANGE_API_SOURCE=file` | Frontend reads from old file-based system instead of MongoDB |
Fallback logic in `DeviceDetail.vue`:
```typescript
const exchanges = await api.getExchanges(deviceId)
.catch(() => legacyService.readExchanges(deviceId))
```
### Data Consistency Checks
- Compare file-based exchange count vs MongoDB count for same device
- Alert if discrepancy > 5% over a 1-hour window
- During dual-write phase, periodically verify both systems agree on latest messages
## Known Risks & Mitigations
| # | Risk | Impact | Mitigation | Related Tasks |
|---|------|--------|------------|---------------|
| 1 | MongoDB write latency > 100ms | Messages queued, potential lag | Write batching, monitor p95, alert at threshold | 14.3, 14.4 |
| 2 | SSE connection limit (100+ concurrent) | Server resource exhaustion | Connection pooling, rate limiting per device (max 5) | 4.5, 14.4 |
| 3 | Message loss during MongoDB failure | Gaps in exchange history | Retry logic (3x for critical), dual-write phase | 2.6, 5.1 |
| 4 | Memory leak in EventEmitter | Gradual server degradation | Listener cleanup on disconnect, connection timeouts (5min/2h) | 4.5, 8.1 |
| 5 | Large XML payloads (>2MB) | Storage bloat, slow queries | Truncation at 2MB, monitoring, alert on frequent truncation | 2.1, 14.2 |
| 6 | Browser memory with 1000+ messages | UI freeze, tab crash | Virtual scrolling, client-side cap at 1000 messages | 11.1, 11.3 |
| 7 | SSE reconnection storm (many clients) | Server overload | Exponential backoff (3s base), max retry limit | 8.1, 7.2 |
| 8 | Concurrent clear operations | Race condition, data corruption | Optimistic locking or queue-based deletion | 7.5, 4.1 |
### Mitigation Checklist
- [ ] Write batching implemented (Risk 1) → Task 2.1
- [ ] SSE connection limit enforced (Risk 2) → Task 4.5
- [ ] Retry logic tested (Risk 3) → Task 2.6, 2.7
- [ ] Listener cleanup verified (Risk 4) → Task 4.5
- [ ] 2MB truncation with logging (Risk 5) → Task 2.1
- [ ] Virtual scrolling + memory cap (Risk 6) → Tasks 11.1, 11.3
- [ ] Reconnection backoff configured (Risk 7) → Task 8.1
- [ ] Concurrent deletion handled (Risk 8) → Task 4.1
## Task Dependency Graph
### Wave Visualization
```
Wave 0: [0.1] [0.2] [0.3] ← Database setup
Wave 1: [1.1] [1.2] ← Config & models
Wave 2: [2.1] [2.4] [2.6] ← Core service impl
Wave 3: [2.2*] [2.3*] [2.5*] [2.7*] ← Core property tests
Wave 4: [4.1] [4.5] [5.1] [5.2] ← API & integration
Wave 5: [4.2*] [4.3*] [4.4*] [4.6*] [4.7*] [7.1-7.5] ← API tests & integration tests
Wave 6: [8.1] [8.2] [8.6] ← Frontend components
Wave 7: [8.3*] [8.4*] [8.5*] [8.7*] [9.1] ← Frontend tests & DeviceDetail
Wave 8: [11.1] [11.2] [11.3] [11.4] ← Robustness
Wave 9: [13.1] [13.2] [13.3] [13.4] [14.1] [14.2] [14.3] [14.4] ← Docs & monitoring
```
### Estimated Timeline
| Wave | Tasks | Estimated Effort | Duration |
|------|-------|-----------------|----------|
| Wave 0 | 0.1, 0.2, 0.3 | 4h | 1 day |
| Wave 1 | 1.1, 1.2 | 3h | 1 day |
| Wave 2 | 2.1, 2.4, 2.6 | 8h | 2 days |
| Wave 3 | 2.2*, 2.3*, 2.5*, 2.7* | 4h | 1 day |
| Wave 4 | 4.1, 4.5, 5.1, 5.2 | 10h | 2-3 days |
| Wave 5 | 4.2*-4.7*, 7.1-7.5 | 12h | 3 days |
| Wave 6 | 8.1, 8.2, 8.6 | 10h | 2-3 days |
| Wave 7 | 8.3*-8.7*, 9.1 | 6h | 1-2 days |
| **MVP Total** | | **~57h** | **~2 weeks** |
| Wave 8 | 11.1-11.4 | 6h | 1-2 days |
| Wave 9 | 13.1-13.4, 14.1-14.4 | 8h | 2 days |
| **Grand Total** | | **~71h** | **~3 weeks** |
```json
{
"waves": [
{ "id": 0, "tasks": ["0.1", "0.2", "0.3"] },
{ "id": 1, "tasks": ["1.1", "1.2"] },
{ "id": 2, "tasks": ["2.1", "2.4", "2.6"] },
{ "id": 3, "tasks": ["2.2", "2.3", "2.5", "2.7"] },
{ "id": 4, "tasks": ["4.1", "4.5", "5.1", "5.2"] },
{ "id": 5, "tasks": ["4.2", "4.3", "4.4", "4.6", "4.7", "7.1", "7.2", "7.3", "7.4", "7.5"] },
{ "id": 6, "tasks": ["8.1", "8.2", "8.6"] },
{ "id": 7, "tasks": ["8.3", "8.4", "8.5", "8.7", "9.1"] },
{ "id": 8, "tasks": ["11.1", "11.2", "11.3", "11.4"] },
{ "id": 9, "tasks": ["13.1", "13.2", "13.3", "13.4", "14.1", "14.2", "14.3", "14.4"] }
]
}
```
+381
View File
@@ -0,0 +1,381 @@
import type { HttpContext } from '@adonisjs/core/http'
import logger from '@adonisjs/core/services/logger'
import ExchangeModel from '#models/exchange'
import type { CwmpExchange } from '#models/exchange'
import { Tr069TraceService } from '#services/tr069_trace_service'
import { ExchangeStoreService } from '#services/exchange_store_service'
import { ExchangeMetricsService } from '#services/exchange_metrics_service'
import exchangeStoreConfig from '#config/exchange_store'
export default class ExchangeController {
/**
* GET /api/v1/tr069-trace/:deviceId/exchanges
*
* Returns exchanges for a device ordered by timestamp ascending.
* Includes metadata about trace status and recording window.
*
* Query params:
* - limit: number of results (default 200, max 1000)
* - after: ISO 8601 timestamp for cursor-based pagination (newer messages)
* - before: ISO 8601 timestamp for reverse pagination (older messages)
* - latest: if "true", returns the most recent `limit` messages (for initial load)
*/
async index({ params, request, response }: HttpContext) {
const startTime = performance.now()
const { deviceId } = params
// Validate limit parameter
const limitParam = request.qs().limit
let limit = 200
if (limitParam !== undefined && limitParam !== '') {
const parsed = Number(limitParam)
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
return response.status(400).json({
error: 'Invalid limit parameter. Must be an integer between 1 and 1000.',
})
}
limit = Math.min(parsed, 1000)
}
// Validate after parameter
const afterParam = request.qs().after as string | undefined
if (afterParam !== undefined && afterParam !== '') {
if (!this.isValidIso8601(afterParam)) {
return response.status(400).json({
error: 'Invalid after parameter. Must be a valid ISO 8601 timestamp.',
})
}
}
// Validate before parameter (for loading older messages in batches)
const beforeParam = request.qs().before as string | undefined
if (beforeParam !== undefined && beforeParam !== '') {
if (!this.isValidIso8601(beforeParam)) {
return response.status(400).json({
error: 'Invalid before parameter. Must be a valid ISO 8601 timestamp.',
})
}
}
// Check if the device has ever been traced (has any exchanges)
const traceService = Tr069TraceService.getInstance()
const isCurrentlyTraced = traceService.isTraced(deviceId)
const hasExchanges = await ExchangeModel.hasExchanges(deviceId)
// Never-traced device: no exchanges exist and not currently traced
if (!hasExchanges && !isCurrentlyTraced) {
return response.status(404).json({
error: `No trace exists for device ${deviceId}`,
})
}
// Fetch exchanges with read timeout (Requirement 7.4)
const latestParam = request.qs().latest as string | undefined
const useLatest = latestParam === 'true'
const timeoutMs = Number(exchangeStoreConfig.readTimeoutMs)
let exchanges: CwmpExchange[]
let isTruncated = false
try {
exchanges = await this.queryWithTimeout(
deviceId,
{ limit, after: afterParam, before: beforeParam, latest: useLatest },
timeoutMs
)
} catch (error) {
if ((error as Error).message === 'QUERY_TIMEOUT') {
logger.warn(
{ correlationId: request.id(), deviceId, timeoutMs },
'[ExchangeController] Read timeout exceeded, returning partial results'
)
isTruncated = true
exchanges = []
} else {
throw error
}
}
// Build metadata
const metadata = await ExchangeModel.getMetadata(deviceId)
// Track API response time
const durationMs = performance.now() - startTime
ExchangeMetricsService.getInstance().recordApiResponseTime('GET /exchanges', durationMs)
const statusCode = 200
logger.info(
{
correlationId: request.id(),
method: 'GET',
path: `/api/v1/tr069-trace/${deviceId}/exchanges`,
statusCode,
durationMs: Math.round(durationMs * 100) / 100,
deviceId,
resultCount: exchanges.length,
isTruncated,
},
'[ExchangeController] API request completed'
)
return response.status(statusCode).json({
exchanges,
metadata: {
traceStatus: isCurrentlyTraced ? 'ACTIVE' : 'STOPPED',
traceStoppedAt: isCurrentlyTraced ? null : metadata.recordedUntil,
recordedFrom: metadata.recordedFrom,
recordedUntil: metadata.recordedUntil,
total: metadata.count,
...(isTruncated ? { isTruncated: true } : {}),
},
})
}
/**
* DELETE /api/v1/tr069-trace/:deviceId/exchanges
*
* Removes all stored exchanges for the device.
* Returns 204 with no body.
*/
async destroy({ params, request, response }: HttpContext) {
const startTime = performance.now()
const { deviceId } = params
await ExchangeModel.deleteByDevice(deviceId)
// Track API response time
const durationMs = performance.now() - startTime
ExchangeMetricsService.getInstance().recordApiResponseTime('DELETE /exchanges', durationMs)
logger.info(
{
correlationId: request.id(),
method: 'DELETE',
path: `/api/v1/tr069-trace/${deviceId}/exchanges`,
statusCode: 204,
durationMs: Math.round(durationMs * 100) / 100,
deviceId,
},
'[ExchangeController] API request completed'
)
return response.status(204).send('')
}
/**
* GET /api/v1/tr069-trace/:deviceId/exchanges/stream (SSE)
*
* SSE endpoint for real-time exchange streaming.
* - Sets SSE headers (Content-Type, Cache-Control, Connection, X-Accel-Buffering)
* - Handles Last-Event-ID for reconnection (resumes from sequenceNumber > lastEventId)
* - Subscribes to ExchangeStoreService events, pushes new exchanges as SSE events
* - Implements server-side timeouts: 5min inactivity, 2h absolute
* - Returns 404 if device is not traced
* - Returns 503 if SSE feature is disabled
*
* Requirements: 6.1, 6.8, 6.9, 6.10, 6.11
*/
async stream({ params, request, response }: HttpContext) {
const startTime = performance.now()
const { deviceId } = params
// Check SSE feature flag
if (!exchangeStoreConfig.featureFlags.sseEnabled) {
return response.status(503).json({ error: 'SSE streaming is currently disabled' })
}
// Requirement 6.11: Return 404 if device is not traced
const traceService = Tr069TraceService.getInstance()
if (!traceService.isTraced(deviceId)) {
return response.status(404).json({ error: 'Device is not being traced' })
}
const nodeRes = response.response
const nodeReq = request.request
// Requirement 6.1: Set SSE headers
nodeRes.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
})
// Send retry interval so clients know how long to wait before reconnecting
nodeRes.write(`retry: ${exchangeStoreConfig.sse.retryIntervalMs}\n\n`)
// Requirement 6.8: Handle Last-Event-ID for reconnection
const lastEventId = request.header('Last-Event-ID')
if (lastEventId) {
const seqNumber = Number(lastEventId)
if (!Number.isNaN(seqNumber)) {
try {
const missedExchanges = await ExchangeModel.findAfterSequence(deviceId, seqNumber)
for (const exchange of missedExchanges) {
this.sendSSEEvent(nodeRes, exchange)
}
if (missedExchanges.length > 0) {
logger.info(
{
correlationId: request.id(),
deviceId,
event: 'sse_reconnect_replay',
lastEventId: seqNumber,
replayedCount: missedExchanges.length,
},
'[SSE] Replayed missed exchanges on reconnection'
)
}
} catch (error) {
logger.error(
{
correlationId: request.id(),
deviceId,
event: 'sse_reconnect_error',
lastEventId,
error: (error as Error).message,
},
'[SSE] Error fetching missed exchanges for reconnection'
)
}
}
}
// Subscribe to new events from ExchangeStoreService
const exchangeStore = ExchangeStoreService.getInstance()
let closed = false
// Track SSE stream setup time
const setupDurationMs = performance.now() - startTime
ExchangeMetricsService.getInstance().recordApiResponseTime('GET /exchanges/stream', setupDurationMs)
const listener = (exchange: any) => {
if (closed) return
this.sendSSEEvent(nodeRes, exchange)
resetInactivityTimer()
// Track SSE push latency (time from persist/emit to client write)
if (exchange._emitTimestamp) {
const pushLatencyMs = performance.now() - exchange._emitTimestamp
ExchangeMetricsService.getInstance().recordSsePushLatency(pushLatencyMs)
}
}
exchangeStore.subscribe(deviceId, listener)
// Structured log: SSE connection established
const isReconnection = !!lastEventId
logger.info(
{
correlationId: request.id(),
deviceId,
event: isReconnection ? 'sse_reconnect' : 'sse_connect',
lastEventId: lastEventId || null,
setupDurationMs: Math.round(setupDurationMs * 100) / 100,
},
`[SSE] Connection ${isReconnection ? 'reconnected' : 'established'}`
)
// Requirement 6.10: Timeout management
const inactivityTimeoutMs = exchangeStoreConfig.sse.inactivityTimeoutMs
const absoluteTimeoutMs = exchangeStoreConfig.sse.absoluteTimeoutMs
let inactivityTimer: ReturnType<typeof setTimeout> = setTimeout(
() => close('inactivity'),
inactivityTimeoutMs
)
const absoluteTimer: ReturnType<typeof setTimeout> = setTimeout(
() => close('absolute'),
absoluteTimeoutMs
)
function resetInactivityTimer() {
clearTimeout(inactivityTimer)
inactivityTimer = setTimeout(() => close('inactivity'), inactivityTimeoutMs)
}
function close(reason?: string) {
if (closed) return
closed = true
exchangeStore.unsubscribe(deviceId, listener)
clearTimeout(inactivityTimer)
clearTimeout(absoluteTimer)
const connectionDurationMs = performance.now() - startTime
logger.info(
{
correlationId: request.id(),
deviceId,
event: 'sse_disconnect',
reason: reason || 'unknown',
connectionDurationMs: Math.round(connectionDurationMs * 100) / 100,
},
'[SSE] Connection closed'
)
try {
nodeRes.end()
} catch {
// Response may already be closed
}
}
// Client disconnect
nodeReq.on('close', () => close('client_disconnect'))
}
/**
* Send a single SSE event to the client.
* Format:
* id: <sequenceNumber>
* data: <JSON-encoded CwmpExchange>
*
* Requirement 6.9: Event id equals the exchange's sequenceNumber
*/
private sendSSEEvent(
res: import('node:http').ServerResponse,
exchange: any
): void {
const data = JSON.stringify(exchange)
res.write(`id: ${exchange.sequenceNumber}\ndata: ${data}\n\n`)
}
/**
* Validates that a string is a valid ISO 8601 timestamp.
* Checks that it parses to a valid Date and has the expected format patterns.
*/
private isValidIso8601(value: string): boolean {
const date = new Date(value)
if (isNaN(date.getTime())) {
return false
}
// Must match common ISO 8601 patterns:
// YYYY-MM-DD, YYYY-MM-DDTHH:mm:ss, YYYY-MM-DDTHH:mm:ssZ, YYYY-MM-DDTHH:mm:ss.sssZ, etc.
const iso8601Pattern =
/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z|[+-]\d{2}:?\d{2})?)?$/
return iso8601Pattern.test(value)
}
/**
* Execute a findByDevice query with a timeout.
* If the query exceeds the timeout, throws an error with message 'QUERY_TIMEOUT'.
* This supports lazy loading with `before`/`latest` parameters for batch loading.
*
* Requirements: 7.4 (partial results on timeout)
*/
private async queryWithTimeout(
deviceId: string,
options: { limit: number; after?: string; before?: string; latest?: boolean },
timeoutMs: number
): Promise<CwmpExchange[]> {
const queryPromise = ExchangeModel.findByDevice(deviceId, options)
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('QUERY_TIMEOUT')), timeoutMs)
})
return Promise.race([queryPromise, timeoutPromise])
}
}
+111 -1
View File
@@ -1,5 +1,7 @@
import type { HttpContext } from '@adonisjs/core/http'
import { getMongoDb } from '#providers/mongodb_provider'
import { verifyCwmpExchangesIndexes } from '#services/exchange_index_checker'
import { ExchangeStoreService } from '#services/exchange_store_service'
export default class HealthController {
async checklife({ response }: HttpContext) {
@@ -10,9 +12,117 @@ export default class HealthController {
try {
const db = getMongoDb()
await db.command({ ping: 1 })
return response.json({ status: 'OK', version: '1.0.0' })
const indexCheck = await verifyCwmpExchangesIndexes(db).catch(() => null)
return response.json({
status: 'OK',
version: '1.0.0',
indexes: indexCheck
? {
cwmp_exchanges: {
allPresent: indexCheck.allPresent,
indexes: indexCheck.indexes,
},
}
: undefined,
})
} catch {
return response.status(503).json({ status: 'KO', version: '1.0.0' })
}
}
/**
* GET /api/v1/health/tr069-exchanges
*
* Health check endpoint for the TR-069 exchange viewer subsystem.
* Reports:
* - MongoDB connectivity and latency
* - Active SSE connection count
* - Collection stats (document count, size)
* - Overall status: healthy / degraded / unhealthy
*
* Status logic:
* - healthy: MongoDB connected, latency < 100ms, indexes present
* - degraded: MongoDB connected but latency >= 100ms, or missing indexes
* - unhealthy: MongoDB unreachable or ping fails
*/
async checkTr069Exchanges({ response }: HttpContext) {
const timestamp = new Date().toISOString()
let mongoConnected = false
let latencyMs = -1
let collectionStats: { count: number; sizeMb: number } | null = null
let indexCheck: Awaited<ReturnType<typeof verifyCwmpExchangesIndexes>> | null = null
// Check MongoDB connectivity and measure latency
try {
const db = getMongoDb()
const start = Date.now()
await db.command({ ping: 1 })
latencyMs = Date.now() - start
mongoConnected = true
// Get collection stats
const collection = db.collection('cwmp_exchanges')
const count = await collection.estimatedDocumentCount()
let sizeMb = 0
try {
const stats = await db.command({ collStats: 'cwmp_exchanges' })
sizeMb = Math.round((stats.size / (1024 * 1024)) * 100) / 100
} catch {
// Collection may not exist yet or collStats may not be available
sizeMb = 0
}
collectionStats = { count, sizeMb }
// Check indexes
indexCheck = await verifyCwmpExchangesIndexes(db)
} catch {
mongoConnected = false
}
// Get active SSE connection count
const exchangeStore = ExchangeStoreService.getInstance()
const activeConnections = exchangeStore.activeConnectionCount
// Determine overall status
let status: 'healthy' | 'degraded' | 'unhealthy'
if (!mongoConnected) {
status = 'unhealthy'
} else if (latencyMs >= 100 || (indexCheck && !indexCheck.allPresent)) {
status = 'degraded'
} else {
status = 'healthy'
}
const statusCode = status === 'unhealthy' ? 503 : 200
return response.status(statusCode).json({
status,
mongodb: {
connected: mongoConnected,
latency_ms: latencyMs >= 0 ? latencyMs : null,
collections: collectionStats
? {
cwmp_exchanges: {
count: collectionStats.count,
size_mb: collectionStats.sizeMb,
},
}
: null,
indexes: indexCheck
? {
allPresent: indexCheck.allPresent,
details: indexCheck.indexes,
}
: null,
},
sse: {
active_connections: activeConnections,
},
timestamp,
})
}
}
+35 -2
View File
@@ -3,6 +3,7 @@ import type { NextFn } from '@adonisjs/core/types/http'
// @ts-ignore - xml-formatter types conflict with ESM interop
import formatXml from 'xml-formatter'
import { Tr069TraceService } from '#services/tr069_trace_service'
import { ExchangeStoreService } from '#services/exchange_store_service'
import logger from '@adonisjs/core/services/logger'
/**
@@ -10,7 +11,11 @@ import logger from '@adonisjs/core/services/logger'
*
* Buffers SOAP request/response. After next():
* - Always logs a brief summary (INFO) to the app logger.
* - For traced devices, writes full SOAP payloads to logs/tr069/{deviceId}.log.
* - For traced devices:
* 1. Persists raw XML to ExchangeStoreService (new structured MongoDB storage)
* 2. Writes pretty-printed XML to file-based logs (legacy, kept for Phase 1 dual-write safety)
*
* The ExchangeStoreService calls are fire-and-forget: they never block CWMP processing.
*/
export default class CwmpLoggerMiddleware {
private prettyXml(xml: string): string {
@@ -52,11 +57,39 @@ export default class CwmpLoggerMiddleware {
const isTraced = deviceId ? traceService.isTraced(deviceId) : false
if (isTraced && deviceId) {
// New system: persist raw XML to MongoDB (fire-and-forget, never blocks CWMP)
// Ordering is guaranteed by the per-device queue in ExchangeStoreService
const exchangeStore = ExchangeStoreService.getInstance()
// Persist CPE→ACS (including empty POST)
exchangeStore.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId,
rawXml: rawBody || '',
}).catch(() => {})
// Persist ACS→CPE response (will queue after the CPE→ACS above)
if (capturedResponseXml) {
exchangeStore.persistExchange({
deviceId,
direction: 'ACS→CPE',
sessionId,
rawXml: capturedResponseXml,
}).catch(() => {})
}
// Old system: file-based writes (kept for Phase 1 dual-write rollback safety)
if (rawBody && rawBody.trim() !== '') {
traceService.writeExchange(deviceId, 'CPE→ACS', sessionId, this.prettyXml(rawBody))
}
if (capturedResponseXml) {
traceService.writeExchange(deviceId, 'ACS→CPE', sessionId, this.prettyXml(capturedResponseXml))
traceService.writeExchange(
deviceId,
'ACS→CPE',
sessionId,
this.prettyXml(capturedResponseXml)
)
}
} else if (rawBody && rawBody.trim() !== '') {
// Brief app-level log only (no SOAP body)
+202
View File
@@ -0,0 +1,202 @@
import { BaseModel } from '#database/base_model'
import { getMongoDb } from '#providers/mongodb_provider'
export interface CwmpExchange {
deviceId: string
direction: 'CPE→ACS' | 'ACS→CPE'
timestamp: string
sessionId: string
cwmpMethod: string
rawXml: string
sequenceNumber: number
createdAt?: Date
}
export interface ExchangeMetadata {
recordedFrom: string | null
recordedUntil: string | null
count: number
}
export default class ExchangeModel extends BaseModel {
static collectionName = 'cwmp_exchanges'
/**
* Find exchanges for a device, ordered by timestamp ascending.
* Supports cursor-based pagination with `after` (newer) and `before` (older) parameters.
* When `before` is used, returns the most recent `limit` messages before that timestamp (ascending order).
* When `latest` is true, returns the most recent `limit` messages (ascending order).
*/
static async findByDevice(
deviceId: string,
options?: { limit?: number; after?: string; before?: string; latest?: boolean }
): Promise<CwmpExchange[]> {
const collection = this.getCollection()
const filter: any = { deviceId }
if (options?.after) {
filter.timestamp = { $gt: options.after }
} else if (options?.before) {
filter.timestamp = { $lt: options.before }
}
const limit = options?.limit ?? 200
// When using `latest` or `before`, we want the most recent messages.
// Query in descending order then reverse to return in ascending order.
if (options?.latest || options?.before) {
const results = await collection
.find(filter)
.sort({ timestamp: -1 })
.limit(limit)
.toArray() as unknown as CwmpExchange[]
return results.reverse()
}
return collection
.find(filter)
.sort({ timestamp: 1 })
.limit(limit)
.toArray() as unknown as Promise<CwmpExchange[]>
}
/**
* Find exchanges with sequence number greater than given value.
* Used for SSE reconnection via Last-Event-ID.
*/
static async findAfterSequence(deviceId: string, seqNumber: number): Promise<CwmpExchange[]> {
const collection = this.getCollection()
return collection
.find({ deviceId, sequenceNumber: { $gt: seqNumber } })
.sort({ sequenceNumber: 1 })
.toArray() as unknown as Promise<CwmpExchange[]>
}
/**
* Delete all exchanges for a device.
* Returns the number of deleted documents.
*/
static async deleteByDevice(deviceId: string): Promise<number> {
const collection = this.getCollection()
const result = await collection.deleteMany({ deviceId })
return result.deletedCount
}
/**
* Get the maximum sequence number for a device.
* Returns 0 if no exchanges exist.
*/
static async getMaxSequenceNumber(deviceId: string): Promise<number> {
const collection = this.getCollection()
const result = await collection
.find({ deviceId })
.sort({ sequenceNumber: -1 })
.limit(1)
.toArray()
return result.length > 0 ? result[0].sequenceNumber : 0
}
/**
* Count exchanges for a device.
*/
static async countByDevice(deviceId: string): Promise<number> {
const collection = this.getCollection()
return collection.countDocuments({ deviceId })
}
/**
* Delete oldest exchanges exceeding the cap for a device.
* Keeps the most recent `maxCount` exchanges (by sequenceNumber).
*/
static async deleteOldestBeyondCap(deviceId: string, maxCount: number): Promise<void> {
const collection = this.getCollection()
const count = await collection.countDocuments({ deviceId })
if (count <= maxCount) return
const toDelete = count - maxCount
const oldest = await collection
.find({ deviceId })
.sort({ sequenceNumber: 1 })
.limit(toDelete)
.project({ sequenceNumber: 1 })
.toArray()
if (oldest.length === 0) return
const maxSeqToDelete = oldest[oldest.length - 1].sequenceNumber
await collection.deleteMany({
deviceId,
sequenceNumber: { $lte: maxSeqToDelete },
})
}
/**
* Check if any exchange exists for a device (ever traced).
*/
static async hasExchanges(deviceId: string): Promise<boolean> {
const collection = this.getCollection()
const doc = await collection.findOne({ deviceId }, { projection: { _id: 1 } })
return doc !== null
}
/**
* Get metadata (oldest and newest timestamps, count).
*/
static async getMetadata(deviceId: string): Promise<ExchangeMetadata> {
const collection = this.getCollection()
const count = await collection.countDocuments({ deviceId })
if (count === 0) {
return { recordedFrom: null, recordedUntil: null, count: 0 }
}
const [oldest] = await collection
.find({ deviceId })
.sort({ timestamp: 1 })
.limit(1)
.project({ timestamp: 1 })
.toArray()
const [newest] = await collection
.find({ deviceId })
.sort({ timestamp: -1 })
.limit(1)
.project({ timestamp: 1 })
.toArray()
return {
recordedFrom: oldest?.timestamp ?? null,
recordedUntil: newest?.timestamp ?? null,
count,
}
}
/**
* Ensure required indexes exist on the cwmp_exchanges collection.
* Creates indexes for:
* - (deviceId, timestamp): efficient retrieval by device ordered by time
* - (deviceId, sequenceNumber) unique: atomic sequence numbering, Last-Event-ID queries
* - (createdAt) TTL 24h: auto-remove old exchanges
*/
static async ensureIndexes(): Promise<void> {
const db = getMongoDb()
const collection = db.collection('cwmp_exchanges')
await collection.createIndex(
{ deviceId: 1, timestamp: 1 },
{ name: 'idx_deviceId_timestamp', background: true }
)
await collection.createIndex(
{ deviceId: 1, sequenceNumber: 1 },
{ unique: true, name: 'idx_deviceId_sequenceNumber', background: true }
)
await collection.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 86400, name: 'idx_createdAt_ttl', background: true }
)
}
}
+1 -1
View File
@@ -368,7 +368,7 @@ export default class ConfigurationEngine {
// Compare values (loose: both as strings to handle type mismatches)
// If the desired type is numeric, compare numerically
const desiredType = desired.type || ''
const desiredType: string = desired.type || ''
const isNumeric = desiredType === 'unsignedInt' || desiredType === 'int' ||
desiredType === 'xsd:unsignedInt' || desiredType === 'xsd:int'
const isBool = desiredType === 'boolean' || desiredType === 'xsd:boolean'
+87
View File
@@ -0,0 +1,87 @@
import { Db } from 'mongodb'
import { getMongoDb } from '#providers/mongodb_provider'
/**
* Expected indexes on the `cwmp_exchanges` collection.
* Each entry describes the index name and key pattern for verification.
*/
const EXPECTED_INDEXES = [
{
name: 'idx_deviceId_timestamp',
key: { deviceId: 1, timestamp: 1 },
description: 'Compound index (deviceId, timestamp)',
},
{
name: 'idx_deviceId_sequenceNumber',
key: { deviceId: 1, sequenceNumber: 1 },
description: 'Unique index (deviceId, sequenceNumber)',
},
{
name: 'idx_createdAt_ttl',
key: { createdAt: 1 },
description: 'TTL index (createdAt, 86400s)',
},
]
export interface IndexCheckResult {
name: string
description: string
exists: boolean
}
export interface IndexVerificationResult {
collection: string
allPresent: boolean
indexes: IndexCheckResult[]
}
/**
* Verifies that all expected indexes exist on the cwmp_exchanges collection.
* Returns a structured result with per-index status.
*/
export async function verifyCwmpExchangesIndexes(db?: Db): Promise<IndexVerificationResult> {
const database = db ?? getMongoDb()
const collection = database.collection('cwmp_exchanges')
let existingIndexes: { name?: string }[]
try {
existingIndexes = await collection.indexes()
} catch {
// Collection may not exist yet
existingIndexes = []
}
const existingNames = new Set(existingIndexes.map((idx) => idx.name))
const indexes: IndexCheckResult[] = EXPECTED_INDEXES.map((expected) => ({
name: expected.name,
description: expected.description,
exists: existingNames.has(expected.name),
}))
const allPresent = indexes.every((idx) => idx.exists)
return {
collection: 'cwmp_exchanges',
allPresent,
indexes,
}
}
/**
* Runs index verification at startup and logs warnings for missing indexes.
* Intended to be called after MongoDB provider has booted.
*/
export async function verifyIndexesAtStartup(db: Db, log: { warn: (msg: string) => void; info: (msg: string) => void }): Promise<void> {
const result = await verifyCwmpExchangesIndexes(db)
if (result.allPresent) {
log.info('[ExchangeIndexCheck] All cwmp_exchanges indexes verified OK')
return
}
const missing = result.indexes.filter((idx) => !idx.exists)
for (const idx of missing) {
log.warn(`[ExchangeIndexCheck] Missing index on cwmp_exchanges: ${idx.name} (${idx.description})`)
}
}
+222
View File
@@ -0,0 +1,222 @@
import logger from '@adonisjs/core/services/logger'
/**
* Tracks percentile-based timing metrics for the TR-069 Exchange Viewer subsystem.
*
* Collects:
* - MongoDB write latency (p50, p95, p99)
* - SSE push latency (persist → client receive)
* - API response times by endpoint
*
* Exposes metrics via structured logs (no Prometheus setup required).
* Periodically logs aggregated stats every `flushIntervalMs` milliseconds.
*/
export interface MetricsSummary {
count: number
p50: number
p95: number
p99: number
min: number
max: number
avg: number
}
export interface AggregatedMetrics {
mongoWriteLatency: MetricsSummary | null
ssePushLatency: MetricsSummary | null
apiResponseTimes: Record<string, MetricsSummary>
collectedAt: string
windowMs: number
}
const DEFAULT_FLUSH_INTERVAL_MS = 60_000 // 1 minute
const DEFAULT_MAX_SAMPLES = 10_000 // cap memory usage
export class ExchangeMetricsService {
private static instance: ExchangeMetricsService
private mongoWriteSamples: number[] = []
private ssePushSamples: number[] = []
private apiResponseSamples: Map<string, number[]> = new Map()
private flushIntervalMs: number
private maxSamples: number
private flushTimer: ReturnType<typeof setInterval> | null = null
private lastFlushAt: number = Date.now()
private constructor(
flushIntervalMs: number = DEFAULT_FLUSH_INTERVAL_MS,
maxSamples: number = DEFAULT_MAX_SAMPLES
) {
this.flushIntervalMs = flushIntervalMs
this.maxSamples = maxSamples
}
static getInstance(
flushIntervalMs?: number,
maxSamples?: number
): ExchangeMetricsService {
if (!ExchangeMetricsService.instance) {
ExchangeMetricsService.instance = new ExchangeMetricsService(flushIntervalMs, maxSamples)
}
return ExchangeMetricsService.instance
}
/**
* Reset singleton (for testing only).
*/
static resetInstance(): void {
if (ExchangeMetricsService.instance) {
ExchangeMetricsService.instance.stop()
}
ExchangeMetricsService.instance = undefined as any
}
/**
* Start the periodic flush timer.
* Safe to call multiple times (idempotent).
*/
start(): void {
if (this.flushTimer) return
this.lastFlushAt = Date.now()
this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs)
// Prevent the timer from keeping the process alive
if (this.flushTimer.unref) {
this.flushTimer.unref()
}
}
/**
* Stop the periodic flush timer and perform a final flush.
*/
stop(): void {
if (this.flushTimer) {
clearInterval(this.flushTimer)
this.flushTimer = null
}
}
/**
* Record a MongoDB write latency sample (in milliseconds).
*/
recordMongoWriteLatency(durationMs: number): void {
this.addSample(this.mongoWriteSamples, durationMs)
}
/**
* Record an SSE push latency sample (in milliseconds).
* This is the time from persist completion to the client receiving the event.
*/
recordSsePushLatency(durationMs: number): void {
this.addSample(this.ssePushSamples, durationMs)
}
/**
* Record an API response time sample (in milliseconds) for a specific endpoint.
* @param endpoint - e.g. "GET /exchanges", "DELETE /exchanges", "GET /exchanges/stream"
*/
recordApiResponseTime(endpoint: string, durationMs: number): void {
if (!this.apiResponseSamples.has(endpoint)) {
this.apiResponseSamples.set(endpoint, [])
}
this.addSample(this.apiResponseSamples.get(endpoint)!, durationMs)
}
/**
* Compute aggregated metrics without clearing samples.
* Useful for health checks or on-demand inspection.
*/
getAggregatedMetrics(): AggregatedMetrics {
const now = Date.now()
const windowMs = now - this.lastFlushAt
const apiResponseTimes: Record<string, MetricsSummary> = {}
for (const [endpoint, samples] of this.apiResponseSamples) {
const summary = this.computeSummary(samples)
if (summary) {
apiResponseTimes[endpoint] = summary
}
}
return {
mongoWriteLatency: this.computeSummary(this.mongoWriteSamples),
ssePushLatency: this.computeSummary(this.ssePushSamples),
apiResponseTimes,
collectedAt: new Date(now).toISOString(),
windowMs,
}
}
/**
* Flush aggregated metrics to structured logs and reset samples.
*/
flush(): AggregatedMetrics {
const metrics = this.getAggregatedMetrics()
// Only log if there are any samples
const hasData =
metrics.mongoWriteLatency !== null ||
metrics.ssePushLatency !== null ||
Object.keys(metrics.apiResponseTimes).length > 0
if (hasData) {
logger.info(
{ metrics },
'[ExchangeMetrics] Aggregated performance metrics'
)
}
// Reset samples
this.mongoWriteSamples = []
this.ssePushSamples = []
this.apiResponseSamples.clear()
this.lastFlushAt = Date.now()
return metrics
}
/**
* Compute percentile summary for a set of samples.
* Returns null if no samples are available.
*/
computeSummary(samples: number[]): MetricsSummary | null {
if (samples.length === 0) return null
const sorted = [...samples].sort((a, b) => a - b)
const count = sorted.length
const sum = sorted.reduce((acc, v) => acc + v, 0)
return {
count,
p50: this.percentile(sorted, 50),
p95: this.percentile(sorted, 95),
p99: this.percentile(sorted, 99),
min: sorted[0],
max: sorted[count - 1],
avg: Math.round((sum / count) * 100) / 100,
}
}
/**
* Calculate percentile value from a sorted array using nearest-rank method.
*/
private percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0
if (sorted.length === 1) return sorted[0]
const index = Math.ceil((p / 100) * sorted.length) - 1
return sorted[Math.max(0, Math.min(index, sorted.length - 1))]
}
/**
* Add a sample to a samples array, respecting the max capacity.
* When at capacity, the oldest sample is dropped (FIFO).
*/
private addSample(samples: number[], value: number): void {
if (samples.length >= this.maxSamples) {
samples.shift()
}
samples.push(value)
}
}
+370
View File
@@ -0,0 +1,370 @@
import { EventEmitter } from 'node:events'
import { getMongoDb } from '#providers/mongodb_provider'
import ExchangeModel from '#models/exchange'
import type { CwmpExchange } from '#models/exchange'
import exchangeStoreConfig from '#config/exchange_store'
import logger from '@adonisjs/core/services/logger'
import { ExchangeMetricsService } from '#services/exchange_metrics_service'
export interface PersistOptions {
deviceId: string
direction: 'CPE→ACS' | 'ACS→CPE'
sessionId: string
rawXml: string
}
const MAX_RAW_XML_BYTES = 2 * 1024 * 1024 // 2MB
/**
* Critical CWMP methods that warrant retry on persistence failure.
* These are RPC calls, responses, and faults that carry important protocol data.
*/
export const CRITICAL_METHODS = new Set([
'Inform',
'GetParameterValues',
'GetParameterValuesResponse',
'SetParameterValues',
'SetParameterValuesResponse',
'AddObject',
'DeleteObject',
'Download',
'Upload',
'Reboot',
'FactoryReset',
'Fault',
])
/**
* Non-critical CWMP methods that are discarded immediately on failure.
*/
export const NON_CRITICAL_METHODS = new Set(['Empty', 'Unknown'])
export class ExchangeStoreService {
private static instance: ExchangeStoreService
private emitter: EventEmitter
private _activeConnectionCount: number = 0
/** Per-device queue to serialize persist operations and guarantee ordering */
private deviceQueues: Map<string, Promise<any>> = new Map()
private constructor() {
this.emitter = new EventEmitter()
this.emitter.setMaxListeners(100)
}
static getInstance(): ExchangeStoreService {
if (!ExchangeStoreService.instance) {
ExchangeStoreService.instance = new ExchangeStoreService()
}
return ExchangeStoreService.instance
}
/**
* Extract CWMP method name from SOAP XML body.
* Reads the first child element name under the `cwmp:` namespace prefix.
* Returns "Empty" if the XML is empty/whitespace-only.
* Returns "Unknown" if no `cwmp:` namespace element is found.
*/
extractCwmpMethod(xml: string): string {
if (!xml || xml.trim() === '') {
return 'Empty'
}
// Try to find the cwmp method inside soap-env:Body first (skips cwmp:ID in Header)
const bodyMatch = xml.match(/<soap-env:Body[^>]*>[\s\S]*?<cwmp:(\w+)[\s/>]/)
let method: string | null = null
if (bodyMatch) {
method = bodyMatch[1]
} else {
// Fallback: match any cwmp: element that is NOT "ID"
const allMatches = xml.matchAll(/<cwmp:(\w+)[\s/>]/g)
for (const m of allMatches) {
if (m[1] !== 'ID') {
method = m[1]
break
}
}
}
if (!method) {
return 'Unknown'
}
// For Inform messages, extract event codes to enrich the display
if (method === 'Inform') {
const events = this.extractInformEvents(xml)
if (events.length > 0) {
return `Inform (${events.join(', ')})`
}
}
return method
}
/**
* Extract event codes from an Inform SOAP message.
* Looks for <EventCode> elements and returns short labels like "BOOT", "PERIODIC", etc.
*/
private extractInformEvents(xml: string): string[] {
const eventRegex = /<EventCode[^>]*>([^<]+)<\/EventCode>/g
const events: string[] = []
let match: RegExpExecArray | null
while ((match = eventRegex.exec(xml)) !== null) {
const raw = match[1].trim()
// Extract just the label part (e.g., "0 BOOTSTRAP" → "BOOTSTRAP", "2 PERIODIC" → "PERIODIC")
const label = raw.replace(/^\d+\s+/, '')
if (label) {
events.push(label)
}
}
return events
}
/**
* Persist an exchange with per-device queuing to guarantee strict ordering.
* Each device's exchanges are serialized — no concurrent persists for the same device.
* This method is fire-and-forget: it never throws and never blocks CWMP processing.
* Checks the FEATURE_EXCHANGE_PERSISTENCE flag before persisting.
*/
async persistExchange(options: PersistOptions): Promise<CwmpExchange | null> {
if (!exchangeStoreConfig.featureFlags.persistence) {
return null
}
// Queue the operation to serialize per-device
const prev = this.deviceQueues.get(options.deviceId) || Promise.resolve()
const current = prev.then(() => this.doPersistExchange(options)).catch(() => null)
this.deviceQueues.set(options.deviceId, current)
return current
}
/**
* Internal persist implementation (called within the per-device queue).
*/
private async doPersistExchange(options: PersistOptions): Promise<CwmpExchange | null> {
try {
let rawXml = options.rawXml || ''
// Truncate rawXml at 2MB with a log warning
if (Buffer.byteLength(rawXml, 'utf-8') > MAX_RAW_XML_BYTES) {
logger.warn(
{ deviceId: options.deviceId, originalSize: Buffer.byteLength(rawXml, 'utf-8') },
'[ExchangeStore] rawXml exceeds 2MB, truncating'
)
rawXml = rawXml.slice(0, MAX_RAW_XML_BYTES)
}
const cwmpMethod = this.extractCwmpMethod(rawXml)
const sequenceNumber = await this.getNextSequenceNumber(options.deviceId)
const timestamp = new Date().toISOString()
const exchange: CwmpExchange = {
deviceId: options.deviceId,
direction: options.direction,
timestamp,
sessionId: options.sessionId,
cwmpMethod,
rawXml,
sequenceNumber,
createdAt: new Date(),
}
const isCritical = this.isCriticalMethod(cwmpMethod)
// Track MongoDB write latency
const writeStart = performance.now()
await this.persistWithRetry(exchange, isCritical)
const writeLatencyMs = performance.now() - writeStart
ExchangeMetricsService.getInstance().recordMongoWriteLatency(writeLatencyMs)
// Structured log: persistence success with all required fields
logger.info(
{
deviceId: options.deviceId,
cwmpMethod,
direction: options.direction,
sequenceNumber,
durationMs: Math.round(writeLatencyMs * 100) / 100,
operation: 'persist_exchange',
status: 'success',
isCritical,
sessionId: options.sessionId,
},
'[ExchangeStore] Exchange persisted successfully'
)
// Enforce message cap after successful persist (non-blocking)
await this.enforceMessageCap(options.deviceId)
// Emit event for SSE subscribers (attach emit timestamp for SSE push latency tracking)
;(exchange as any)._emitTimestamp = performance.now()
this.emitter.emit(`exchange:${options.deviceId}`, exchange)
return exchange
} catch (error) {
logger.error(
{
deviceId: options.deviceId,
cwmpMethod: this.extractCwmpMethod(options.rawXml || ''),
direction: options.direction,
operation: 'persist_exchange',
status: 'failure',
error: (error as Error).message,
sessionId: options.sessionId,
},
'[ExchangeStore] Failed to persist exchange'
)
return null
}
}
/**
* Determine if a CWMP method is critical (warrants retry on failure).
* Critical methods include RPC calls, responses, and faults.
* Non-critical methods (Empty, Unknown) are discarded immediately on failure.
*/
isCriticalMethod(cwmpMethod: string): boolean {
return CRITICAL_METHODS.has(cwmpMethod)
}
/**
* Retry logic for critical message persistence.
*
* - Critical messages: up to maxAttempts retries with exponential backoff
* (baseDelayMs * 2^attempt: 1s, 2s, 4s by default)
* - Non-critical messages: discarded immediately on first failure
* - NEVER throws — logs error and discards after all retries exhausted
* - Ensures CWMP session is never interrupted regardless of persistence outcome
*/
async persistWithRetry(doc: CwmpExchange, isCritical: boolean): Promise<void> {
const { maxAttempts, baseDelayMs } = exchangeStoreConfig.retry
// First attempt (always)
try {
const collection = ExchangeModel.getCollection()
await collection.insertOne(doc as any)
return
} catch (error) {
if (!isCritical) {
// Non-critical: log and discard immediately
logger.warn(
{ deviceId: doc.deviceId, cwmpMethod: doc.cwmpMethod, error: String(error) },
'[ExchangeStore] Non-critical message persistence failed — discarding'
)
return
}
logger.warn(
{ deviceId: doc.deviceId, cwmpMethod: doc.cwmpMethod, error: String(error) },
'[ExchangeStore] Critical message persistence failed — starting retries'
)
}
// Retry loop for critical messages
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const delayMs = baseDelayMs * Math.pow(2, attempt)
await this.sleep(delayMs)
try {
const collection = ExchangeModel.getCollection()
await collection.insertOne(doc as any)
logger.info(
{ deviceId: doc.deviceId, cwmpMethod: doc.cwmpMethod, attempt: attempt + 1 },
'[ExchangeStore] Critical message persisted after retry'
)
return
} catch (error) {
logger.warn(
{
deviceId: doc.deviceId,
cwmpMethod: doc.cwmpMethod,
attempt: attempt + 1,
maxAttempts,
error: String(error),
},
'[ExchangeStore] Retry failed'
)
}
}
// All retries exhausted — log error and discard (NEVER throw)
logger.error(
{ deviceId: doc.deviceId, cwmpMethod: doc.cwmpMethod, maxAttempts },
'[ExchangeStore] All retries exhausted for critical message — discarding'
)
}
/**
* Sleep utility for exponential backoff.
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Get next sequence number for a device using atomic increment.
* Uses the `cwmp_exchange_counters` collection with findOneAndUpdate + upsert.
*/
async getNextSequenceNumber(deviceId: string): Promise<number> {
const db = getMongoDb()
const counters = db.collection('cwmp_exchange_counters')
const result = await counters.findOneAndUpdate(
{ _id: deviceId as any },
{ $inc: { seq: 1 } },
{ upsert: true, returnDocument: 'after' }
)
return result!.seq as number
}
/**
* Subscribe to exchange events for a specific device.
* The listener will be called with each new CwmpExchange as it's persisted.
*/
subscribe(deviceId: string, listener: (exchange: CwmpExchange) => void): void {
this.emitter.on(`exchange:${deviceId}`, listener)
this._activeConnectionCount++
}
/**
* Unsubscribe from exchange events for a specific device.
*/
unsubscribe(deviceId: string, listener: (exchange: CwmpExchange) => void): void {
this.emitter.off(`exchange:${deviceId}`, listener)
this._activeConnectionCount = Math.max(0, this._activeConnectionCount - 1)
}
/**
* Get the current count of active SSE connections (subscriptions).
*/
get activeConnectionCount(): number {
return this._activeConnectionCount
}
/**
* Enforce max messages cap per device.
* Deletes oldest messages when count exceeds maxMessagesPerDevice.
* Errors are logged but never thrown (non-blocking).
*/
async enforceMessageCap(deviceId: string): Promise<void> {
try {
const maxCount = exchangeStoreConfig.maxMessagesPerDevice
const count = await ExchangeModel.countByDevice(deviceId)
if (count > maxCount) {
await ExchangeModel.deleteOldestBeyondCap(deviceId, maxCount)
logger.info(
{ deviceId, count, maxCount, deleted: count - maxCount },
'[ExchangeStore] Message cap enforced, oldest messages deleted'
)
}
} catch (error) {
logger.error(
{ deviceId, error },
'[ExchangeStore] Error enforcing message cap (non-blocking)'
)
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import env from '#start/env'
const exchangeStoreConfig = {
/**
* Maximum number of exchanges stored per device.
* Oldest messages are deleted when this cap is exceeded.
*/
maxMessagesPerDevice: env.get('EXCHANGE_STORE_MAX_MESSAGES', 5000),
/**
* TTL (time-to-live) in seconds for exchanges.
* MongoDB TTL index auto-removes documents older than this value.
* Default: 86400 seconds (24 hours).
*/
ttlSeconds: env.get('EXCHANGE_STORE_TTL_SECONDS', 86400),
/**
* SSE (Server-Sent Events) configuration.
*/
sse: {
/**
* Inactivity timeout in milliseconds.
* Connection is closed if no new exchange is pushed within this period.
* Default: 300000ms (5 minutes).
*/
inactivityTimeoutMs: env.get('EXCHANGE_STORE_SSE_INACTIVITY_TIMEOUT_MS', 300_000),
/**
* Absolute timeout in milliseconds.
* Connection is closed after this duration regardless of activity.
* Default: 7200000ms (2 hours).
*/
absoluteTimeoutMs: env.get('EXCHANGE_STORE_SSE_ABSOLUTE_TIMEOUT_MS', 7_200_000),
/**
* Maximum concurrent SSE connections per device.
* Additional connections beyond this limit are rejected.
*/
maxConnectionsPerDevice: env.get('EXCHANGE_STORE_SSE_MAX_CONNECTIONS_PER_DEVICE', 5),
/**
* Retry interval in milliseconds sent to clients via SSE `retry` field.
* Clients use this value when reconnecting after a disconnect.
* Default: 3000ms (3 seconds).
*/
retryIntervalMs: env.get('EXCHANGE_STORE_SSE_RETRY_INTERVAL_MS', 3000),
},
/**
* Retry policy for critical message persistence.
*/
retry: {
/**
* Maximum number of retry attempts for critical messages.
*/
maxAttempts: env.get('EXCHANGE_STORE_RETRY_MAX_ATTEMPTS', 3),
/**
* Base delay in milliseconds for exponential backoff.
* Actual delays: baseDelayMs, baseDelayMs*2, baseDelayMs*4, ...
* Default: 1000ms → retries at 1s, 2s, 4s.
*/
baseDelayMs: env.get('EXCHANGE_STORE_RETRY_BASE_DELAY_MS', 1000),
},
/**
* Read timeout in milliseconds for exchange retrieval queries.
* If a query exceeds this duration, partial results are returned with `isTruncated: true`.
* Default: 5000ms (5 seconds).
*/
readTimeoutMs: env.get('EXCHANGE_STORE_READ_TIMEOUT_MS', 5000),
/**
* Feature flags for gradual rollout and rollback.
*/
featureFlags: {
/**
* Enable/disable MongoDB persistence of exchanges.
* When false, exchanges are not written to MongoDB (CWMP sessions unaffected).
*/
persistence: env.get('FEATURE_EXCHANGE_PERSISTENCE', true),
/**
* Enable/disable SSE streaming endpoint.
* When false, the SSE endpoint returns 503 and clients fall back to REST polling.
*/
sseEnabled: env.get('FEATURE_SSE_ENABLED', true),
/**
* Data source for the Exchange API.
* 'mongodb': read from structured MongoDB collection (new system).
* 'file': read from legacy file-based logs (old system).
*/
apiSource: env.get('FEATURE_EXCHANGE_API_SOURCE', 'mongodb') as 'mongodb' | 'file',
},
}
export default exchangeStoreConfig
@@ -0,0 +1,33 @@
import { MongoMigration } from '../mongo_migrator.js'
export default class CreateCwmpExchangesCollection extends MongoMigration {
async up(): Promise<void> {
const exchanges = this.db.collection('cwmp_exchanges')
// Compound index for efficient retrieval by device, ordered by time
await exchanges.createIndex(
{ deviceId: 1, timestamp: 1 },
{ name: 'idx_deviceId_timestamp' }
)
// Unique index for atomic sequence numbering and Last-Event-ID queries
await exchanges.createIndex(
{ deviceId: 1, sequenceNumber: 1 },
{ unique: true, name: 'idx_deviceId_sequenceNumber' }
)
// TTL index to auto-remove exchanges older than 24h
await exchanges.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 86400, name: 'idx_createdAt_ttl' }
)
// Create counters collection for atomic sequence number generation
this.db.collection('cwmp_exchange_counters')
}
async down(): Promise<void> {
await this.db.dropCollection('cwmp_exchanges').catch(() => {})
await this.db.dropCollection('cwmp_exchange_counters').catch(() => {})
}
}
+552
View File
@@ -0,0 +1,552 @@
/**
* Database seeder for cwmp_exchanges collection.
*
* Generates realistic CWMP exchange data for integration/manual testing.
* Can be run standalone via: npx tsx database/seeders/cwmp_exchanges_seeder.ts
*
* Configuration via environment variables:
* SEED_DEVICE_COUNT - Number of devices to generate (default: 5)
* SEED_EXCHANGES_MIN - Minimum exchanges per device (default: 20)
* SEED_EXCHANGES_MAX - Maximum exchanges per device (default: 100)
* MONGODB_URI - MongoDB connection string
* MONGODB_DB_NAME - Database name (default: adam-acs)
*/
import { MongoClient, type Db } from 'mongodb'
// ─── Configuration ──────────────────────────────────────────────────────────
interface SeederConfig {
deviceCount: number
exchangesMin: number
exchangesMax: number
mongoUri: string
dbName: string
}
function getConfig(): SeederConfig {
return {
deviceCount: Number(process.env.SEED_DEVICE_COUNT ?? 5),
exchangesMin: Number(process.env.SEED_EXCHANGES_MIN ?? 20),
exchangesMax: Number(process.env.SEED_EXCHANGES_MAX ?? 100),
mongoUri: process.env.MONGODB_URI ?? 'mongodb://localhost:27017',
dbName: process.env.MONGODB_DB_NAME ?? 'adam-acs',
}
}
// ─── CWMP Method Templates ──────────────────────────────────────────────────
type Direction = 'CPE→ACS' | 'ACS→CPE'
interface ExchangeTemplate {
cwmpMethod: string
direction: Direction
xmlGenerator: () => string
}
const MANUFACTURERS = ['Sagemcom', 'Huawei', 'ZTE', 'Nokia', 'Technicolor']
const PRODUCT_CLASSES = ['Livebox 4', 'BM632w', 'ZXHN H298A', 'G-240W-A', 'TG789vac']
const OUI_LIST = ['F8B7FF', '202BC1', '001D08', '00E0FC', '74BD7B']
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
function randomChoice<T>(arr: T[]): T {
return arr[randomInt(0, arr.length - 1)]
}
function generateDeviceId(index: number): string {
const oui = OUI_LIST[index % OUI_LIST.length]
const serial = `DEVICE-${String(index + 1).padStart(3, '0')}`
return `${oui}-${PRODUCT_CLASSES[index % PRODUCT_CLASSES.length]}-${serial}`
}
function generateSessionId(): string {
const srcIp = `10.0.${randomInt(0, 255)}.${randomInt(1, 254)}`
const srcPort = randomInt(30000, 65000)
return `${srcIp}:${srcPort}->192.168.1.1:8080`
}
// ─── XML Body Generators ────────────────────────────────────────────────────
function wrapSoapEnvelope(body: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:soap-enc="http://schemas.xmlsoap.org/soap/encoding/"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Header>
<cwmp:ID soap-env:mustUnderstand="1">${randomInt(100000, 999999)}</cwmp:ID>
</soap-env:Header>
<soap-env:Body>
${body}
</soap-env:Body>
</soap-env:Envelope>`
}
function generateInformXml(deviceIndex: number): string {
const oui = OUI_LIST[deviceIndex % OUI_LIST.length]
const manufacturer = MANUFACTURERS[deviceIndex % MANUFACTURERS.length]
const productClass = PRODUCT_CLASSES[deviceIndex % PRODUCT_CLASSES.length]
const serial = `DEVICE-${String(deviceIndex + 1).padStart(3, '0')}`
const events = randomChoice([
['0 BOOTSTRAP', '1 BOOT'],
['2 PERIODIC'],
['1 BOOT'],
['6 CONNECTION REQUEST'],
])
const eventString = events
.map(
(e, i) =>
`<EventStruct><EventCode>${e}</EventCode><CommandKey>${i}</CommandKey></EventStruct>`
)
.join('\n ')
return wrapSoapEnvelope(`<cwmp:Inform>
<DeviceId>
<Manufacturer>${manufacturer}</Manufacturer>
<OUI>${oui}</OUI>
<ProductClass>${productClass}</ProductClass>
<SerialNumber>${serial}</SerialNumber>
</DeviceId>
<Event soap-enc:arrayType="cwmp:EventStruct[${events.length}]">
${eventString}
</Event>
<MaxEnvelopes>1</MaxEnvelopes>
<CurrentTime>${new Date().toISOString()}</CurrentTime>
<RetryCount>0</RetryCount>
<ParameterList soap-enc:arrayType="cwmp:ParameterValueStruct[4]">
<ParameterValueStruct>
<Name>Device.DeviceInfo.HardwareVersion</Name>
<Value xsi:type="xsd:string">v2.1</Value>
</ParameterValueStruct>
<ParameterValueStruct>
<Name>Device.DeviceInfo.SoftwareVersion</Name>
<Value xsi:type="xsd:string">SR40_sip-fr-4.62.0</Value>
</ParameterValueStruct>
<ParameterValueStruct>
<Name>Device.ManagementServer.ConnectionRequestURL</Name>
<Value xsi:type="xsd:string">http://10.0.0.${randomInt(1, 254)}:7547/</Value>
</ParameterValueStruct>
<ParameterValueStruct>
<Name>Device.ManagementServer.ParameterKey</Name>
<Value xsi:type="xsd:string"></Value>
</ParameterValueStruct>
</ParameterList>
</cwmp:Inform>`)
}
function generateInformResponseXml(): string {
return wrapSoapEnvelope(`<cwmp:InformResponse>
<MaxEnvelopes>1</MaxEnvelopes>
</cwmp:InformResponse>`)
}
function generateGetParameterValuesXml(paths: string[]): string {
const paramNames = paths.map((p) => `<string>${p}</string>`).join('\n ')
return wrapSoapEnvelope(`<cwmp:GetParameterValues>
<ParameterNames soap-enc:arrayType="xsd:string[${paths.length}]">
${paramNames}
</ParameterNames>
</cwmp:GetParameterValues>`)
}
function generateGetParameterValuesResponseXml(paths: string[]): string {
const params = paths
.map(
(p) => `<ParameterValueStruct>
<Name>${p}</Name>
<Value xsi:type="xsd:string">value_${randomInt(1, 9999)}</Value>
</ParameterValueStruct>`
)
.join('\n ')
return wrapSoapEnvelope(`<cwmp:GetParameterValuesResponse>
<ParameterList soap-enc:arrayType="cwmp:ParameterValueStruct[${paths.length}]">
${params}
</ParameterList>
</cwmp:GetParameterValuesResponse>`)
}
function generateSetParameterValuesXml(): string {
return wrapSoapEnvelope(`<cwmp:SetParameterValues>
<ParameterList soap-enc:arrayType="cwmp:ParameterValueStruct[2]">
<ParameterValueStruct>
<Name>Device.ManagementServer.PeriodicInformInterval</Name>
<Value xsi:type="xsd:unsignedInt">${randomInt(60, 3600)}</Value>
</ParameterValueStruct>
<ParameterValueStruct>
<Name>Device.DeviceInfo.ProvisioningCode</Name>
<Value xsi:type="xsd:string">PROV-${randomInt(1000, 9999)}</Value>
</ParameterValueStruct>
</ParameterList>
<ParameterKey>key-${randomInt(1, 999)}</ParameterKey>
</cwmp:SetParameterValues>`)
}
function generateSetParameterValuesResponseXml(): string {
return wrapSoapEnvelope(`<cwmp:SetParameterValuesResponse>
<Status>0</Status>
</cwmp:SetParameterValuesResponse>`)
}
function generateGetParameterNamesXml(): string {
const path = randomChoice([
'Device.',
'Device.DeviceInfo.',
'Device.ManagementServer.',
'Device.WiFi.',
])
return wrapSoapEnvelope(`<cwmp:GetParameterNames>
<ParameterPath>${path}</ParameterPath>
<NextLevel>1</NextLevel>
</cwmp:GetParameterNames>`)
}
function generateGetParameterNamesResponseXml(): string {
const params = ['Device.DeviceInfo.', 'Device.ManagementServer.', 'Device.WiFi.', 'Device.IP.']
const paramList = params
.map(
(p) => `<ParameterInfoStruct>
<Name>${p}</Name>
<Writable>0</Writable>
</ParameterInfoStruct>`
)
.join('\n ')
return wrapSoapEnvelope(`<cwmp:GetParameterNamesResponse>
<ParameterList soap-enc:arrayType="cwmp:ParameterInfoStruct[${params.length}]">
${paramList}
</ParameterList>
</cwmp:GetParameterNamesResponse>`)
}
function generateDownloadXml(): string {
return wrapSoapEnvelope(`<cwmp:Download>
<CommandKey>firmware-update-${randomInt(1, 99)}</CommandKey>
<FileType>1 Firmware Upgrade Image</FileType>
<URL>http://firmware.example.com/fw-v${randomInt(1, 5)}.${randomInt(0, 9)}.bin</URL>
<Username>admin</Username>
<Password>****</Password>
<FileSize>${randomInt(1000000, 50000000)}</FileSize>
<TargetFileName></TargetFileName>
<DelaySeconds>0</DelaySeconds>
<SuccessURL></SuccessURL>
<FailureURL></FailureURL>
</cwmp:Download>`)
}
function generateDownloadResponseXml(): string {
const status = randomChoice([0, 1]) // 0=completed, 1=in progress
return wrapSoapEnvelope(`<cwmp:DownloadResponse>
<Status>${status}</Status>
<StartTime>${new Date().toISOString()}</StartTime>
<CompleteTime>0001-01-01T00:00:00Z</CompleteTime>
</cwmp:DownloadResponse>`)
}
function generateRebootXml(): string {
return wrapSoapEnvelope(`<cwmp:Reboot>
<CommandKey>reboot-${randomInt(1, 999)}</CommandKey>
</cwmp:Reboot>`)
}
function generateRebootResponseXml(): string {
return wrapSoapEnvelope(`<cwmp:RebootResponse/>`)
}
function generateFaultXml(): string {
const faultCode = randomChoice(['9001', '9002', '9003', '9005', '9007'])
const faultMessages: Record<string, string> = {
'9001': 'Request denied',
'9002': 'Internal error',
'9003': 'Invalid arguments',
'9005': 'Invalid parameter name',
'9007': 'Invalid parameter value',
}
return wrapSoapEnvelope(`<soap-env:Fault>
<faultcode>Client</faultcode>
<faultstring>CWMP fault</faultstring>
<detail>
<cwmp:Fault>
<FaultCode>${faultCode}</FaultCode>
<FaultString>${faultMessages[faultCode]}</FaultString>
</cwmp:Fault>
</detail>
</soap-env:Fault>`)
}
function generateTransferCompleteXml(): string {
return wrapSoapEnvelope(`<cwmp:TransferComplete>
<CommandKey>firmware-update-${randomInt(1, 99)}</CommandKey>
<FaultStruct>
<FaultCode>0</FaultCode>
<FaultString></FaultString>
</FaultStruct>
<StartTime>${new Date().toISOString()}</StartTime>
<CompleteTime>${new Date().toISOString()}</CompleteTime>
</cwmp:TransferComplete>`)
}
function generateTransferCompleteResponseXml(): string {
return wrapSoapEnvelope(`<cwmp:TransferCompleteResponse/>`)
}
// ─── Exchange Sequence Patterns ─────────────────────────────────────────────
/** Realistic CWMP session exchange patterns (request/response pairs) */
const SESSION_PATTERNS: ExchangeTemplate[][] = [
// Inform session start
[
{ cwmpMethod: 'Inform', direction: 'CPE→ACS', xmlGenerator: () => '' },
{ cwmpMethod: 'InformResponse', direction: 'ACS→CPE', xmlGenerator: generateInformResponseXml },
],
// GetParameterValues
[
{
cwmpMethod: 'GetParameterValues',
direction: 'ACS→CPE',
xmlGenerator: () =>
generateGetParameterValuesXml([
'Device.DeviceInfo.SoftwareVersion',
'Device.DeviceInfo.HardwareVersion',
'Device.ManagementServer.PeriodicInformInterval',
]),
},
{
cwmpMethod: 'GetParameterValuesResponse',
direction: 'CPE→ACS',
xmlGenerator: () =>
generateGetParameterValuesResponseXml([
'Device.DeviceInfo.SoftwareVersion',
'Device.DeviceInfo.HardwareVersion',
'Device.ManagementServer.PeriodicInformInterval',
]),
},
],
// SetParameterValues
[
{
cwmpMethod: 'SetParameterValues',
direction: 'ACS→CPE',
xmlGenerator: generateSetParameterValuesXml,
},
{
cwmpMethod: 'SetParameterValuesResponse',
direction: 'CPE→ACS',
xmlGenerator: generateSetParameterValuesResponseXml,
},
],
// GetParameterNames
[
{
cwmpMethod: 'GetParameterNames',
direction: 'ACS→CPE',
xmlGenerator: generateGetParameterNamesXml,
},
{
cwmpMethod: 'GetParameterNamesResponse',
direction: 'CPE→ACS',
xmlGenerator: generateGetParameterNamesResponseXml,
},
],
// Download (firmware)
[
{ cwmpMethod: 'Download', direction: 'ACS→CPE', xmlGenerator: generateDownloadXml },
{
cwmpMethod: 'DownloadResponse',
direction: 'CPE→ACS',
xmlGenerator: generateDownloadResponseXml,
},
],
// Reboot
[
{ cwmpMethod: 'Reboot', direction: 'ACS→CPE', xmlGenerator: generateRebootXml },
{ cwmpMethod: 'RebootResponse', direction: 'CPE→ACS', xmlGenerator: generateRebootResponseXml },
],
// TransferComplete (CPE-initiated after download)
[
{
cwmpMethod: 'TransferComplete',
direction: 'CPE→ACS',
xmlGenerator: generateTransferCompleteXml,
},
{
cwmpMethod: 'TransferCompleteResponse',
direction: 'ACS→CPE',
xmlGenerator: generateTransferCompleteResponseXml,
},
],
// Fault response
[{ cwmpMethod: 'Fault', direction: 'CPE→ACS', xmlGenerator: generateFaultXml }],
// Empty POST (session end)
[{ cwmpMethod: 'Empty', direction: 'CPE→ACS', xmlGenerator: () => '' }],
]
// ─── Exchange Generator ─────────────────────────────────────────────────────
interface CwmpExchangeDoc {
deviceId: string
direction: Direction
timestamp: Date
sessionId: string
cwmpMethod: string
rawXml: string
sequenceNumber: number
createdAt: Date
}
function generateExchangesForDevice(
deviceId: string,
deviceIndex: number,
count: number
): CwmpExchangeDoc[] {
const exchanges: CwmpExchangeDoc[] = []
const now = Date.now()
const twentyFourHoursMs = 24 * 60 * 60 * 1000
// Distribute exchanges over the last 24h
let sequenceNumber = 1
let currentTimeMs = now - twentyFourHoursMs + randomInt(0, 60000)
while (exchanges.length < count) {
// Start a new session
const sessionId = generateSessionId()
// Each session has an Inform + some exchanges
const sessionSize = Math.min(randomInt(4, 12), count - exchanges.length)
// Always start session with Inform
const informXml = generateInformXml(deviceIndex)
const informTimestamp = new Date(currentTimeMs)
exchanges.push({
deviceId,
direction: 'CPE→ACS',
timestamp: informTimestamp,
sessionId,
cwmpMethod: 'Inform',
rawXml: informXml,
sequenceNumber: sequenceNumber++,
createdAt: informTimestamp,
})
currentTimeMs += randomInt(50, 200) // 50-200ms between messages
if (exchanges.length >= count) break
// InformResponse
const informRespTimestamp = new Date(currentTimeMs)
exchanges.push({
deviceId,
direction: 'ACS→CPE',
timestamp: informRespTimestamp,
sessionId,
cwmpMethod: 'InformResponse',
rawXml: generateInformResponseXml(),
sequenceNumber: sequenceNumber++,
createdAt: informRespTimestamp,
})
currentTimeMs += randomInt(50, 200)
// Fill rest of session with random patterns
let remaining = sessionSize - 2
while (remaining > 0 && exchanges.length < count) {
// Pick a random pattern (skip Inform pattern at index 0)
const patternIndex = randomInt(1, SESSION_PATTERNS.length - 1)
const pattern = SESSION_PATTERNS[patternIndex]
for (const template of pattern) {
if (exchanges.length >= count) break
remaining--
const ts = new Date(currentTimeMs)
exchanges.push({
deviceId,
direction: template.direction,
timestamp: ts,
sessionId,
cwmpMethod: template.cwmpMethod,
rawXml: template.xmlGenerator(),
sequenceNumber: sequenceNumber++,
createdAt: ts,
})
currentTimeMs += randomInt(50, 300)
}
}
// Gap between sessions (30s to 30min)
currentTimeMs += randomInt(30_000, 1_800_000)
}
return exchanges.slice(0, count)
}
// ─── Seeder Runner ──────────────────────────────────────────────────────────
export async function seed(db: Db, config?: Partial<SeederConfig>): Promise<void> {
const cfg = { ...getConfig(), ...config }
const collection = db.collection('cwmp_exchanges')
const countersCollection = db.collection('cwmp_exchange_counters')
console.log(`[Seeder] Seeding cwmp_exchanges with:`)
console.log(` - ${cfg.deviceCount} devices`)
console.log(` - ${cfg.exchangesMin}-${cfg.exchangesMax} exchanges per device`)
// Clear existing data
await collection.deleteMany({})
await countersCollection.deleteMany({})
console.log(`[Seeder] Cleared existing cwmp_exchanges and counters`)
let totalInserted = 0
for (let i = 0; i < cfg.deviceCount; i++) {
const deviceId = generateDeviceId(i)
const exchangeCount = randomInt(cfg.exchangesMin, cfg.exchangesMax)
const exchanges = generateExchangesForDevice(deviceId, i, exchangeCount)
// Bulk insert exchanges
if (exchanges.length > 0) {
await collection.insertMany(exchanges)
// Update counter for the device
const maxSeq = exchanges[exchanges.length - 1].sequenceNumber
await countersCollection.updateOne(
{ _id: deviceId as any },
{ $set: { seq: maxSeq } },
{ upsert: true }
)
totalInserted += exchanges.length
console.log(`[Seeder] Device "${deviceId}": ${exchanges.length} exchanges (seq 1-${maxSeq})`)
}
}
console.log(`[Seeder] Done. Total exchanges inserted: ${totalInserted}`)
}
// ─── Standalone Execution ───────────────────────────────────────────────────
async function main(): Promise<void> {
const config = getConfig()
console.log(`[Seeder] Connecting to MongoDB at ${config.mongoUri}/${config.dbName}...`)
const client = new MongoClient(config.mongoUri)
await client.connect()
const db = client.db(config.dbName)
try {
await seed(db, config)
} finally {
await client.close()
console.log(`[Seeder] MongoDB connection closed.`)
}
}
// Run directly if executed as a script
const isMainModule = process.argv[1]?.includes('cwmp_exchanges_seeder')
if (isMainModule) {
main().catch((err) => {
console.error('[Seeder] Fatal error:', err)
process.exit(1)
})
}
+475
View File
@@ -0,0 +1,475 @@
openapi: 3.0.3
info:
title: TR-069 Exchange Viewer API
description: |
API REST pour la récupération, la suppression et le streaming en temps réel
des échanges CWMP/TR-069 capturés lors d'une session de trace sur un device.
## Architecture
Les échanges SOAP sont capturés par le `CwmpLoggerMiddleware`, persistés dans
MongoDB via `ExchangeStoreService`, et diffusés en temps réel via Server-Sent Events (SSE).
## Authentification
Tous les endpoints nécessitent une session authentifiée (cookie-based via AdonisJS).
## Feature Flags
| Variable d'environnement | Effet |
|--------------------------|-------|
| `FEATURE_EXCHANGE_PERSISTENCE=false` | Désactive l'écriture MongoDB |
| `FEATURE_SSE_ENABLED=false` | Désactive le SSE (endpoint renvoie 503) |
| `FEATURE_EXCHANGE_API_SOURCE=file` | Lecture depuis l'ancien système fichier |
version: 1.0.0
contact:
name: adam-acs Team
servers:
- url: http://localhost:8080/api/v1
description: Local development
paths:
/tr069-trace/{deviceId}/exchanges:
get:
operationId: getExchanges
summary: Récupérer les échanges CWMP d'un device
description: |
Retourne les échanges CWMP structurés pour un device tracé, ordonnés par
timestamp ascending. Inclut des métadonnées sur le statut de la trace et
la fenêtre d'enregistrement.
**Pagination cursor-based** : utiliser `after` pour charger les messages plus
récents, `before` pour charger les messages plus anciens, ou `latest=true`
pour obtenir les N messages les plus récents (chargement initial).
tags:
- Exchanges
parameters:
- $ref: '#/components/parameters/deviceId'
- name: limit
in: query
required: false
description: |
Nombre maximum de messages retournés.
- Défaut : 200
- Minimum : 1
- Maximum : 1000
- Retourne HTTP 400 si non-numérique ou < 1
schema:
type: integer
minimum: 1
maximum: 1000
default: 200
example: 50
- name: after
in: query
required: false
description: |
Timestamp ISO 8601 pour la pagination cursor-based (messages plus récents).
Seuls les messages avec un timestamp strictement supérieur à cette valeur
sont retournés. Retourne HTTP 400 si le format est invalide.
schema:
type: string
format: date-time
example: '2025-01-15T10:30:00.000Z'
- name: before
in: query
required: false
description: |
Timestamp ISO 8601 pour la pagination inverse (messages plus anciens).
Seuls les messages avec un timestamp strictement inférieur à cette valeur
sont retournés. Retourne HTTP 400 si le format est invalide.
schema:
type: string
format: date-time
example: '2025-01-15T10:35:00.000Z'
- name: latest
in: query
required: false
description: |
Si `true`, retourne les N messages les plus récents (pour le chargement
initial). Combiné avec `limit` pour contrôler le nombre de messages.
schema:
type: string
enum:
- 'true'
- 'false'
example: 'true'
responses:
'200':
description: Liste des échanges avec métadonnées
content:
application/json:
schema:
$ref: '#/components/schemas/ExchangeListResponse'
examples:
activeTrace:
summary: Device actuellement tracé avec des échanges
value:
exchanges:
- sequenceNumber: 1
timestamp: '2025-01-15T10:30:00.123Z'
direction: 'CPE→ACS'
cwmpMethod: 'Inform'
sessionId: '10.0.0.1:54321->192.168.1.1:8080'
rawXml: '<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cwmp="urn:dslforum-org:cwmp-1-0"><soap-env:Body><cwmp:Inform>...</cwmp:Inform></soap-env:Body></soap-env:Envelope>'
- sequenceNumber: 2
timestamp: '2025-01-15T10:30:00.456Z'
direction: 'ACS→CPE'
cwmpMethod: 'InformResponse'
sessionId: '10.0.0.1:54321->192.168.1.1:8080'
rawXml: '<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cwmp="urn:dslforum-org:cwmp-1-0"><soap-env:Body><cwmp:InformResponse><MaxEnvelopes>1</MaxEnvelopes></cwmp:InformResponse></soap-env:Body></soap-env:Envelope>'
metadata:
traceStatus: 'ACTIVE'
traceStoppedAt: null
recordedFrom: '2025-01-15T10:30:00.123Z'
recordedUntil: '2025-01-15T10:35:42.567Z'
total: 42
stoppedTrace:
summary: Trace arrêtée avec historique conservé
value:
exchanges:
- sequenceNumber: 1
timestamp: '2025-01-15T10:30:00.123Z'
direction: 'CPE→ACS'
cwmpMethod: 'Inform'
sessionId: '10.0.0.1:54321->192.168.1.1:8080'
rawXml: '<soap-env:Envelope>...</soap-env:Envelope>'
metadata:
traceStatus: 'STOPPED'
traceStoppedAt: '2025-01-15T10:40:00.000Z'
recordedFrom: '2025-01-15T10:30:00.123Z'
recordedUntil: '2025-01-15T10:35:42.567Z'
total: 15
emptyTrace:
summary: Device tracé mais aucun échange encore enregistré
value:
exchanges: []
metadata:
traceStatus: 'ACTIVE'
traceStoppedAt: null
recordedFrom: null
recordedUntil: null
total: 0
truncatedResult:
summary: Résultat partiel (timeout de lecture dépassé)
value:
exchanges: []
metadata:
traceStatus: 'ACTIVE'
traceStoppedAt: null
recordedFrom: '2025-01-15T10:30:00.123Z'
recordedUntil: '2025-01-15T10:35:42.567Z'
total: 5000
isTruncated: true
'400':
description: Paramètre de requête invalide
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
invalidLimit:
summary: Paramètre limit invalide
value:
error: 'Invalid limit parameter. Must be an integer between 1 and 1000.'
invalidAfter:
summary: Paramètre after invalide
value:
error: 'Invalid after parameter. Must be a valid ISO 8601 timestamp.'
invalidBefore:
summary: Paramètre before invalide
value:
error: 'Invalid before parameter. Must be a valid ISO 8601 timestamp.'
'404':
description: Le device n'a jamais été tracé (aucun échange historique)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
error: 'No trace exists for device F8B7FF-Livebox4-LB4-TEST-001'
delete:
operationId: deleteExchanges
summary: Supprimer tous les échanges d'un device
description: |
Supprime tous les échanges CWMP stockés pour le device spécifié.
Retourne 204 sans corps de réponse en cas de succès.
Cette opération est irréversible. Si le device est actuellement tracé,
les nouveaux échanges continueront d'être enregistrés après la suppression.
tags:
- Exchanges
parameters:
- $ref: '#/components/parameters/deviceId'
responses:
'204':
description: Échanges supprimés avec succès (pas de corps de réponse)
/tr069-trace/{deviceId}/exchanges/stream:
get:
operationId: streamExchanges
summary: Flux SSE temps réel des échanges CWMP
description: |
Ouvre une connexion HTTP persistante avec `Content-Type: text/event-stream`
qui pousse chaque nouvel échange CWMP dès qu'il est persisté.
## Protocole SSE
La connexion utilise le protocole Server-Sent Events (SSE) standard :
- **Content-Type** : `text/event-stream`
- **Cache-Control** : `no-cache`
- **Connection** : `keep-alive`
- **X-Accel-Buffering** : `no` (pour nginx)
## Format des événements
Chaque événement SSE contient :
```
id: <sequenceNumber>
data: <JSON-encoded CwmpExchange>
```
Exemple d'événement brut :
```
id: 42
data: {"sequenceNumber":42,"timestamp":"2025-01-15T10:30:00.123Z","direction":"CPE→ACS","cwmpMethod":"Inform","sessionId":"10.0.0.1:54321->192.168.1.1:8080","rawXml":"<soap-env:Envelope>...</soap-env:Envelope>"}
```
## Reconnexion (Last-Event-ID)
Lors d'une reconnexion, le client envoie l'en-tête `Last-Event-ID` avec le
`sequenceNumber` du dernier événement reçu. Le serveur reprend alors à partir
du message suivant (sequenceNumber > Last-Event-ID), sans duplication.
Le serveur envoie un champ `retry` au début de la connexion (défaut: 3000ms)
pour configurer l'intervalle de reconnexion automatique du client.
## Timeouts serveur
Le serveur ferme la connexion dans les cas suivants :
- **Inactivité** : aucun nouvel échange poussé pendant 5 minutes
- **Durée absolue** : connexion ouverte depuis plus de 2 heures
Dans les deux cas, le client devrait se reconnecter automatiquement
(mécanisme natif EventSource avec Last-Event-ID).
## Feature Flag
Si `FEATURE_SSE_ENABLED=false`, cet endpoint retourne HTTP 503.
tags:
- Exchanges
- SSE
parameters:
- $ref: '#/components/parameters/deviceId'
- name: Last-Event-ID
in: header
required: false
description: |
Sequence number du dernier événement reçu par le client.
Utilisé pour reprendre le flux après une déconnexion sans duplication.
schema:
type: string
example: '42'
responses:
'200':
description: Flux SSE ouvert avec succès
headers:
Content-Type:
schema:
type: string
example: text/event-stream
Cache-Control:
schema:
type: string
example: no-cache
Connection:
schema:
type: string
example: keep-alive
X-Accel-Buffering:
schema:
type: string
example: 'no'
content:
text/event-stream:
schema:
type: string
description: Flux d'événements SSE (voir description pour le format)
example: |
retry: 3000
id: 1
data: {"sequenceNumber":1,"timestamp":"2025-01-15T10:30:00.123Z","direction":"CPE→ACS","cwmpMethod":"Inform","sessionId":"10.0.0.1:54321->192.168.1.1:8080","rawXml":"<soap-env:Envelope>...</soap-env:Envelope>"}
id: 2
data: {"sequenceNumber":2,"timestamp":"2025-01-15T10:30:00.456Z","direction":"ACS→CPE","cwmpMethod":"InformResponse","sessionId":"10.0.0.1:54321->192.168.1.1:8080","rawXml":"<soap-env:Envelope>...</soap-env:Envelope>"}
'404':
description: Le device n'est pas actuellement tracé
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
error: 'Device is not being traced'
'503':
description: Le streaming SSE est désactivé (feature flag)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
error: 'SSE streaming is currently disabled'
components:
parameters:
deviceId:
name: deviceId
in: path
required: true
description: |
Identifiant du device (format: `OUI-ProductClass-SerialNumber`
ou hash MongoDB `_id`).
schema:
type: string
maxLength: 128
example: 'F8B7FF-Livebox4-LB4-TEST-001'
schemas:
CwmpExchange:
type: object
description: Un échange SOAP CWMP individuel capturé
required:
- sequenceNumber
- timestamp
- direction
- cwmpMethod
- sessionId
- rawXml
properties:
sequenceNumber:
type: integer
description: |
Numéro de séquence monotoniquement croissant par device (commence à 1).
Utilisé comme identifiant d'événement SSE pour la reconnexion.
example: 42
timestamp:
type: string
format: date-time
description: Horodatage UTC de la capture (ISO 8601)
example: '2025-01-15T10:30:00.123Z'
direction:
type: string
enum:
- 'CPE→ACS'
- 'ACS→CPE'
description: |
Direction du message :
- `CPE→ACS` : requête envoyée par le CPE vers l'ACS
- `ACS→CPE` : réponse envoyée par l'ACS vers le CPE
example: 'CPE→ACS'
cwmpMethod:
type: string
description: |
Méthode CWMP extraite du body SOAP :
- Nom de la méthode (ex: `Inform`, `GetParameterValues`, `SetParameterValuesResponse`)
- `"Empty"` si le body XML est vide (empty POST du CPE)
- `"Unknown"` si aucun élément `cwmp:` n'est trouvé dans le body
example: 'Inform'
sessionId:
type: string
description: Identifiant de session TCP (format `IP:port->IP:port`)
example: '10.0.0.1:54321->192.168.1.1:8080'
rawXml:
type: string
description: |
Contenu XML SOAP brut du message (maximum 2 Mo).
Peut être vide pour les messages de type "Empty".
maxLength: 2097152
example: '<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">...</soap-env:Envelope>'
ExchangeMetadata:
type: object
description: Métadonnées sur la session de trace du device
required:
- traceStatus
- traceStoppedAt
- recordedFrom
- recordedUntil
- total
properties:
traceStatus:
type: string
enum:
- ACTIVE
- STOPPED
description: |
Statut actuel de la trace :
- `ACTIVE` : le device est en cours de trace
- `STOPPED` : la trace a été arrêtée (historique conservé)
example: 'ACTIVE'
traceStoppedAt:
type: string
format: date-time
nullable: true
description: Timestamp d'arrêt de la trace (null si active)
example: null
recordedFrom:
type: string
format: date-time
nullable: true
description: Timestamp du plus ancien échange enregistré
example: '2025-01-15T10:30:00.123Z'
recordedUntil:
type: string
format: date-time
nullable: true
description: Timestamp du plus récent échange enregistré
example: '2025-01-15T10:35:42.567Z'
total:
type: integer
description: Nombre total d'échanges stockés pour ce device
example: 42
isTruncated:
type: boolean
description: |
Présent uniquement si le résultat est partiel (timeout de lecture dépassé).
Le client devrait réessayer avec des paramètres de pagination plus restreints.
example: true
ExchangeListResponse:
type: object
description: Réponse de l'endpoint GET /exchanges
required:
- exchanges
- metadata
properties:
exchanges:
type: array
items:
$ref: '#/components/schemas/CwmpExchange'
description: Liste des échanges ordonnés par timestamp ascending
metadata:
$ref: '#/components/schemas/ExchangeMetadata'
ErrorResponse:
type: object
description: Réponse d'erreur standard
required:
- error
properties:
error:
type: string
description: Message d'erreur décrivant le problème
example: 'Invalid limit parameter. Must be an integer between 1 and 1000.'
tags:
- name: Exchanges
description: Gestion des échanges CWMP capturés
- name: SSE
description: Streaming temps réel via Server-Sent Events
+363
View File
@@ -0,0 +1,363 @@
# Deployment Guide: TR-069 Exchange Viewer
## Overview
The TR-069 Exchange Viewer stores CWMP protocol exchanges in MongoDB and streams them in real-time via SSE (Server-Sent Events) to a frontend sequence diagram. This guide covers the infrastructure requirements, configuration, feature flags, and monitoring needed for deployment.
## MongoDB Setup
### Requirements
- **MongoDB version**: 4.4+ (tested with 5.0 and 6.0)
- **Replica set**: Not required. The system uses an in-process EventEmitter for real-time notifications instead of MongoDB Change Streams.
- **Authentication**: Standard MongoDB credentials (same as the existing adam-acs database)
### Collections
The feature creates two MongoDB collections:
| Collection | Purpose |
|-----------|---------|
| `cwmp_exchanges` | Stores structured CWMP exchange documents |
| `cwmp_exchange_counters` | Atomic counters for per-device sequence number generation |
### Indexes
Three indexes are created on `cwmp_exchanges`:
| Name | Fields | Options | Purpose |
|------|--------|---------|---------|
| `idx_deviceId_timestamp` | `{ deviceId: 1, timestamp: 1 }` | — | Efficient retrieval by device, ordered by time |
| `idx_deviceId_sequenceNumber` | `{ deviceId: 1, sequenceNumber: 1 }` | `unique: true` | Atomic sequence numbering, Last-Event-ID queries |
| `idx_createdAt_ttl` | `{ createdAt: 1 }` | `expireAfterSeconds: 86400` | Auto-remove exchanges older than 24 hours |
### Running the Migration
Indexes and collections are created via migration `011_create_cwmp_exchanges_collection.ts`:
```bash
# Run all pending migrations
node ace migration:run
# Verify migration applied
node ace migration:status
```
### Index Verification
On startup, the application checks that all expected indexes exist and logs warnings if any are missing. The health check endpoint also reports index status (see [Health Check](#health-check-endpoint) below).
To manually verify indexes in the MongoDB shell:
```javascript
db.cwmp_exchanges.getIndexes()
// Expected: _id, idx_deviceId_timestamp, idx_deviceId_sequenceNumber, idx_createdAt_ttl
```
### Storage Considerations
- **TTL**: Documents are automatically removed after 24 hours (configurable via `EXCHANGE_STORE_TTL_SECONDS`)
- **Message cap**: Maximum 5000 exchanges per device (configurable via `EXCHANGE_STORE_MAX_MESSAGES`)
- **Document size**: Each exchange document stores raw XML (max 2 MB per message)
- **Estimated storage**: For 100 traced devices with 5000 messages averaging 10 KB each → ~5 GB peak
---
## Environment Variables
### Feature Flags
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `FEATURE_EXCHANGE_PERSISTENCE` | Boolean | `true` | Enable/disable MongoDB persistence of exchanges |
| `FEATURE_SSE_ENABLED` | Boolean | `true` | Enable/disable the SSE streaming endpoint |
| `FEATURE_EXCHANGE_API_SOURCE` | String | `mongodb` | Data source for the Exchange API: `mongodb` or `file` |
### Exchange Store Configuration
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `EXCHANGE_STORE_MAX_MESSAGES` | Number | `5000` | Max exchanges stored per device |
| `EXCHANGE_STORE_TTL_SECONDS` | Number | `86400` | TTL in seconds for auto-removal (24h) |
| `EXCHANGE_STORE_READ_TIMEOUT_MS` | Number | `5000` | Query timeout before returning partial results |
### SSE Configuration
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `EXCHANGE_STORE_SSE_INACTIVITY_TIMEOUT_MS` | Number | `300000` | Close SSE connection after 5 min of inactivity |
| `EXCHANGE_STORE_SSE_ABSOLUTE_TIMEOUT_MS` | Number | `7200000` | Close SSE connection after 2 hours regardless |
| `EXCHANGE_STORE_SSE_MAX_CONNECTIONS_PER_DEVICE` | Number | `5` | Max concurrent SSE connections per device |
| `EXCHANGE_STORE_SSE_RETRY_INTERVAL_MS` | Number | `3000` | Retry interval sent to clients for reconnection |
### Retry Policy
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `EXCHANGE_STORE_RETRY_MAX_ATTEMPTS` | Number | `3` | Max retries for critical message persistence |
| `EXCHANGE_STORE_RETRY_BASE_DELAY_MS` | Number | `1000` | Base delay for exponential backoff (1s, 2s, 4s) |
### Example .env Addition
```dotenv
# TR-069 Exchange Viewer - Feature Flags
FEATURE_EXCHANGE_PERSISTENCE=true
FEATURE_SSE_ENABLED=true
FEATURE_EXCHANGE_API_SOURCE=mongodb
# TR-069 Exchange Viewer - Storage
EXCHANGE_STORE_MAX_MESSAGES=5000
EXCHANGE_STORE_TTL_SECONDS=86400
EXCHANGE_STORE_READ_TIMEOUT_MS=5000
# TR-069 Exchange Viewer - SSE
EXCHANGE_STORE_SSE_INACTIVITY_TIMEOUT_MS=300000
EXCHANGE_STORE_SSE_ABSOLUTE_TIMEOUT_MS=7200000
EXCHANGE_STORE_SSE_MAX_CONNECTIONS_PER_DEVICE=5
EXCHANGE_STORE_SSE_RETRY_INTERVAL_MS=3000
# TR-069 Exchange Viewer - Retry Policy
EXCHANGE_STORE_RETRY_MAX_ATTEMPTS=3
EXCHANGE_STORE_RETRY_BASE_DELAY_MS=1000
```
---
## Feature Flags Usage and Toggle Procedures
The system uses three feature flags for gradual rollout and safe rollback. All flags can be toggled at runtime by changing environment variables and restarting the application.
### FEATURE_EXCHANGE_PERSISTENCE
Controls whether CWMP exchanges are written to MongoDB.
| Value | Behavior |
|-------|----------|
| `true` (default) | Exchanges are persisted to MongoDB for traced devices |
| `false` | No writes to MongoDB; CWMP sessions continue unaffected |
**When to disable:**
- MongoDB is experiencing high latency or unavailability
- Storage exhaustion on the MongoDB server
- You need to temporarily reduce load on the database
**Toggle procedure:**
```bash
# Disable persistence
export FEATURE_EXCHANGE_PERSISTENCE=false
# Restart the application
docker compose restart app
```
> Note: Disabling persistence means no new exchanges are stored. Existing exchanges remain queryable until TTL expires.
### FEATURE_SSE_ENABLED
Controls the real-time streaming endpoint.
| Value | Behavior |
|-------|----------|
| `true` (default) | SSE endpoint is available at `/api/v1/tr069-trace/:deviceId/exchanges/stream` |
| `false` | SSE endpoint returns 503; frontend falls back to REST polling every 5 seconds |
**When to disable:**
- High number of concurrent SSE connections causing resource exhaustion
- Network infrastructure (proxy/load balancer) not supporting long-lived connections
- Debugging connectivity issues
**Toggle procedure:**
```bash
# Disable SSE streaming
export FEATURE_SSE_ENABLED=false
# Restart the application
docker compose restart app
```
> Note: The frontend automatically falls back to REST polling (5-second interval) when SSE is unavailable. No user action required.
### FEATURE_EXCHANGE_API_SOURCE
Controls where the Exchange API reads data from.
| Value | Behavior |
|-------|----------|
| `mongodb` (default) | API reads from the `cwmp_exchanges` MongoDB collection |
| `file` | API reads from the legacy file-based trace system |
**When to switch to `file`:**
- MongoDB data is corrupted or inconsistent
- Rollback to the previous system during migration
- Comparing outputs between old and new systems
**Toggle procedure:**
```bash
# Fallback to file-based system
export FEATURE_EXCHANGE_API_SOURCE=file
# Restart the application
docker compose restart app
```
### Recommended Rollout Sequence
1. **Phase 1 — Dual-write** (default state):
- `FEATURE_EXCHANGE_PERSISTENCE=true`
- `FEATURE_SSE_ENABLED=true`
- `FEATURE_EXCHANGE_API_SOURCE=mongodb`
- Old file-based system continues writing in parallel (dual-write)
2. **Phase 2 — Validate**:
- Compare file-based and MongoDB exchange counts
- Monitor health check for discrepancies
- Verify SSE connections are stable
3. **Phase 3 — Full cutover**:
- Remove file-based writes from `CwmpLoggerMiddleware`
- `FEATURE_EXCHANGE_API_SOURCE=mongodb` (permanently)
### Emergency Rollback
To fully disable the exchange viewer feature without stopping the application:
```bash
export FEATURE_EXCHANGE_PERSISTENCE=false
export FEATURE_SSE_ENABLED=false
export FEATURE_EXCHANGE_API_SOURCE=file
docker compose restart app
```
This restores pre-feature behavior: no MongoDB writes, no SSE connections, and the API reads from the legacy file system.
---
## Health Check Endpoint
### Endpoint
```
GET /api/v1/health
```
The existing health check endpoint (`/api/v1/health`) reports on the overall application status including MongoDB connectivity and `cwmp_exchanges` index status.
### Response Format
```json
{
"status": "OK",
"version": "1.0.0",
"indexes": {
"cwmp_exchanges": {
"allPresent": true,
"indexes": [
{ "name": "idx_deviceId_timestamp", "present": true },
{ "name": "idx_deviceId_sequenceNumber", "present": true },
{ "name": "idx_createdAt_ttl", "present": true }
]
}
}
}
```
### Status Codes
| Status | Code | Meaning |
|--------|------|---------|
| OK | 200 | MongoDB connected, all indexes present |
| KO | 503 | MongoDB unreachable or critical failure |
### Monitoring Recommendations
| Metric | Threshold | Action |
|--------|-----------|--------|
| Health check status `KO` | Any occurrence | Investigate MongoDB connectivity |
| Missing indexes (`allPresent: false`) | Any occurrence | Re-run migration: `node ace migration:run` |
| MongoDB ping latency | > 100ms p95 | Check MongoDB server load |
| Active SSE connections | > 100 total | Consider scaling or limiting connections |
### Integration with Monitoring Tools
The health check endpoint can be used with standard monitoring tools:
```bash
# Simple liveness check (Docker HEALTHCHECK)
curl -f http://localhost:8080/api/v1/health || exit 1
# Kubernetes readiness probe
livenessProbe:
httpGet:
path: /api/v1/health
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
# Nagios/Zabbix check
curl -s http://localhost:8080/api/v1/health | jq -e '.status == "OK"'
```
---
## API Endpoints
The feature exposes three endpoints under the `/api/v1` prefix:
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/tr069-trace/:deviceId/exchanges` | Retrieve exchanges (paginated) |
| DELETE | `/api/v1/tr069-trace/:deviceId/exchanges` | Purge all exchanges for a device |
| GET | `/api/v1/tr069-trace/:deviceId/exchanges/stream` | SSE endpoint for real-time streaming |
---
## Reverse Proxy Configuration
If running behind a reverse proxy (nginx, Traefik, etc.), configure it to support SSE:
### Nginx
```nginx
location /api/v1/tr069-trace/ {
proxy_pass http://app:8080;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 7200s; # Match absolute timeout (2h)
chunked_transfer_encoding off;
}
```
### Traefik
```yaml
http:
middlewares:
sse-headers:
headers:
customResponseHeaders:
X-Accel-Buffering: "no"
services:
adam-acs:
loadBalancer:
responseForwarding:
flushInterval: "0ms"
```
---
## Docker Deployment
No additional Docker services are needed. The feature uses the existing MongoDB instance.
```yaml
# docker-compose.yml (relevant section)
services:
app:
environment:
- FEATURE_EXCHANGE_PERSISTENCE=true
- FEATURE_SSE_ENABLED=true
- FEATURE_EXCHANGE_API_SOURCE=mongodb
depends_on:
- mongodb
```
Ensure the MongoDB container has sufficient storage for exchange data (see [Storage Considerations](#storage-considerations)).
+592
View File
@@ -0,0 +1,592 @@
# Guide de migration : TR-069 Exchange Viewer
## Résumé
Ce guide décrit la migration du système de trace TR-069 depuis le stockage fichier (logs `.log` rotatifs) vers le stockage structuré MongoDB (`cwmp_exchanges`). La migration suit une approche en 3 phases pour garantir zéro perte de données et un rollback possible à chaque étape.
| Phase | Durée | Description | Risque |
|-------|-------|-------------|--------|
| Phase 1 — Dual-Write | Semaines 1-2 | Écriture simultanée fichiers + MongoDB | Faible |
| Phase 2 — Read from MongoDB | Semaines 3-4 | API lit depuis MongoDB, fichiers en backup | Moyen |
| Phase 3 — Deprecate Files | Semaine 5+ | Suppression du système fichier | Irréversible |
---
## Prérequis
### Infrastructure
- MongoDB 5.0+ (pas besoin de replica set — le système utilise un EventEmitter in-process, pas de change streams)
- Espace disque MongoDB : prévoir ~500 Mo pour 10 000 devices actifs avec 5 000 messages max chacun
- L'application adam-acs doit être redémarrée après la migration de base
### Variables d'environnement
```bash
# Feature flags (déjà présents dans config/exchange_store.ts)
FEATURE_EXCHANGE_PERSISTENCE=true # Active les écritures MongoDB
FEATURE_SSE_ENABLED=true # Active le streaming SSE
FEATURE_EXCHANGE_API_SOURCE=mongodb # Source des données API (mongodb | file)
# Configuration optionnelle
EXCHANGE_STORE_MAX_MESSAGES=5000 # Cap messages par device
EXCHANGE_STORE_TTL_SECONDS=86400 # TTL 24h
EXCHANGE_STORE_READ_TIMEOUT_MS=5000 # Timeout requêtes API
```
---
## Phase 1 : Dual-Write (Semaines 1-2)
### Objectif
Écrire simultanément dans les deux systèmes (fichiers ET MongoDB) pour valider la fiabilité du nouveau système sans risque de perte.
### 1.1 Exécuter la migration MongoDB
La migration crée la collection `cwmp_exchanges` et ses index :
```bash
# Exécuter la migration
node ace migration:run --connection=mongodb
```
Cela exécute `database/mongo_migrations/011_create_cwmp_exchanges_collection.ts` qui crée :
- Collection `cwmp_exchanges` avec 3 index :
- `{ deviceId: 1, timestamp: 1 }` — requêtes par device
- `{ deviceId: 1, sequenceNumber: 1 }` (unique) — numérotation séquentielle et reconnexion SSE
- `{ createdAt: 1 }` (TTL: 86400s) — nettoyage automatique après 24h
- Collection `cwmp_exchange_counters` — compteurs atomiques de séquence
### 1.2 Vérifier les index
```bash
# Via mongosh
mongosh --eval "
db.cwmp_exchanges.getIndexes().forEach(idx => {
print(idx.name + ': ' + JSON.stringify(idx.key))
})
"
```
Résultat attendu :
```
_id_: {"_id":1}
idx_deviceId_timestamp: {"deviceId":1,"timestamp":1}
idx_deviceId_sequenceNumber: {"deviceId":1,"sequenceNumber":1}
idx_createdAt_ttl: {"createdAt":1}
```
### 1.3 Activer le dual-write
Le dual-write est déjà implémenté dans `CwmpLoggerMiddleware`. Vérifier les feature flags :
```bash
# .env — Phase 1
FEATURE_EXCHANGE_PERSISTENCE=true
FEATURE_SSE_ENABLED=true
FEATURE_EXCHANGE_API_SOURCE=file # GARDER 'file' en Phase 1
```
Le middleware écrit dans les deux systèmes :
```typescript
// app/middleware/cwmp_logger_middleware.ts (extrait)
if (isTraced && deviceId) {
// Nouveau système : MongoDB (fire-and-forget)
exchangeStore.persistExchange({ deviceId, direction, sessionId, rawXml })
.catch(() => {})
// Ancien système : fichiers (gardé pour rollback)
traceService.writeExchange(deviceId, direction, sessionId, prettyXml)
}
```
### 1.4 Redémarrer l'application
```bash
docker compose build app
docker compose up -d app
```
### 1.5 Vérifier le fonctionnement
Activer le trace sur un device et déclencher un Inform :
```bash
# Vérifier que MongoDB reçoit les données
mongosh --eval "db.cwmp_exchanges.countDocuments({})"
# Vérifier les derniers échanges
mongosh --eval "
db.cwmp_exchanges.find({}).sort({timestamp: -1}).limit(3).forEach(doc => {
print(doc.deviceId + ' | ' + doc.direction + ' | ' + doc.cwmpMethod)
})
"
```
### 1.6 Vérification de cohérence (Dual-Write)
Pendant la Phase 1, vérifier régulièrement que les deux systèmes restent synchronisés :
#### Script de comparaison
```bash
#!/bin/bash
# scripts/verify_dual_write.sh
# Compare le nombre d'échanges entre fichiers et MongoDB pour chaque device tracé
DEVICE_ID=$1
if [ -z "$DEVICE_ID" ]; then
echo "Usage: $0 <deviceId>"
exit 1
fi
# Compteur fichier (nombre de séparateurs '═' = nombre d'échanges)
FILE_COUNT=$(grep -c '═════' "logs/tr069/${DEVICE_ID}.log" 2>/dev/null || echo "0")
# Compteur MongoDB
MONGO_COUNT=$(mongosh --quiet --eval "db.cwmp_exchanges.countDocuments({deviceId: '${DEVICE_ID}'})")
echo "Device: ${DEVICE_ID}"
echo " Fichiers : ${FILE_COUNT} échanges"
echo " MongoDB : ${MONGO_COUNT} échanges"
if [ "$FILE_COUNT" != "$MONGO_COUNT" ]; then
DIFF=$((FILE_COUNT - MONGO_COUNT))
echo " ⚠️ Écart : ${DIFF} messages"
if [ "${DIFF#-}" -gt $((FILE_COUNT / 20)) ]; then
echo " 🚨 Écart > 5% — Investigation requise !"
fi
else
echo " ✅ Cohérent"
fi
```
#### Critères d'acceptation Phase 1
| Critère | Seuil | Action si dépassé |
|---------|-------|-------------------|
| Écart fichier/MongoDB | < 5% | Vérifier les logs d'erreur ExchangeStore |
| Erreurs de persistance | < 0.1% | Vérifier la connectivité MongoDB |
| Latence MongoDB p95 | < 100ms | Vérifier la charge et les index |
| Messages perdus (critiques) | 0 après retries | Vérifier les logs de retry exhaustion |
#### Durée recommandée
Maintenir le dual-write pendant **minimum 1 semaine** avec des vérifications quotidiennes. Passer en Phase 2 uniquement si les critères ci-dessus sont respectés de manière constante.
---
## Phase 2 : Read from MongoDB (Semaines 3-4)
### Objectif
Basculer la lecture des données vers MongoDB tout en gardant les fichiers en écriture backup.
### 2.1 Changer la source de lecture API
```bash
# .env — Phase 2
FEATURE_EXCHANGE_PERSISTENCE=true
FEATURE_SSE_ENABLED=true
FEATURE_EXCHANGE_API_SOURCE=mongodb # ← Changer de 'file' à 'mongodb'
```
### 2.2 Redémarrer l'application
```bash
docker compose restart app
```
### 2.3 Vérifier le fonctionnement
```bash
# Vérifier que l'API retourne des données depuis MongoDB
curl -s "http://localhost:8080/api/v1/tr069-trace/<DEVICE_ID>/exchanges?limit=5" | \
python3 -c "
import sys, json
data = json.load(sys.stdin)
print(f\"Source: MongoDB\")
print(f\"Exchanges: {len(data.get('exchanges', []))} messages\")
print(f\"Metadata: {data.get('metadata', {})}\")
"
```
### 2.4 Vérifier le SSE
```bash
# Ouvrir une connexion SSE (ctrl+C pour arrêter)
curl -N -H "Accept: text/event-stream" \
"http://localhost:8080/api/v1/tr069-trace/<DEVICE_ID>/exchanges/stream"
```
Déclencher un Inform sur le device tracé et vérifier qu'un événement SSE arrive en temps réel.
### 2.5 Vérifier le fallback
Si MongoDB est indisponible, le frontend doit retomber sur les fichiers :
```typescript
// Logique dans DeviceDetail.vue
const exchanges = await api.getExchanges(deviceId)
.catch(() => legacyService.readExchanges(deviceId))
```
### 2.6 Critères de passage Phase 2 → Phase 3
| Critère | Seuil | Vérifié |
|---------|-------|---------|
| API MongoDB fonctionne sans erreur | 48h continues | ☐ |
| SSE pousse les événements en < 500ms | p95 vérifié | ☐ |
| Aucune perte de message critique | 0 sur 48h | ☐ |
| Reconnexion SSE (Last-Event-ID) valide | Testé manuellement | ☐ |
| Purge (DELETE) fonctionne | 204 retourné | ☐ |
---
## Phase 3 : Deprecate Old System (Semaine 5+)
### Objectif
Supprimer le système fichier une fois que MongoDB est validé en production.
### 3.1 Supprimer le dual-write du middleware
Dans `app/middleware/cwmp_logger_middleware.ts`, retirer les appels au système fichier :
```typescript
// AVANT (dual-write)
if (isTraced && deviceId) {
exchangeStore.persistExchange({ deviceId, direction, sessionId, rawXml }).catch(() => {})
traceService.writeExchange(deviceId, direction, sessionId, prettyXml) // ← SUPPRIMER
}
// APRÈS (MongoDB uniquement)
if (isTraced && deviceId) {
exchangeStore.persistExchange({ deviceId, direction, sessionId, rawXml }).catch(() => {})
}
```
### 3.2 Supprimer les dépendances obsolètes
1. Retirer l'import `xml-formatter` du middleware (le formatage n'est plus nécessaire côté persistance)
2. Supprimer les appels à `Tr069TraceService.writeExchange()` dans le middleware
3. **NE PAS** supprimer `Tr069TraceService` lui-même — il est encore utilisé pour gérer l'état trace (start/stop)
### 3.3 Nettoyer les fichiers de logs
```bash
# Archiver les anciens logs avant suppression
tar -czf logs/tr069-archive-$(date +%Y%m%d).tar.gz logs/tr069/
rm -rf logs/tr069/*.log*
```
### 3.4 Mise à jour de la configuration
```bash
# .env — Phase 3 (état final)
FEATURE_EXCHANGE_PERSISTENCE=true
FEATURE_SSE_ENABLED=true
FEATURE_EXCHANGE_API_SOURCE=mongodb
```
### 3.5 Vérification finale
```bash
# Vérifier qu'aucun fichier .log n'est créé
ls -la logs/tr069/
# Vérifier que le système fonctionne uniquement via MongoDB
curl -s "http://localhost:8080/api/v1/health/tr069-exchanges" | python3 -m json.tool
```
---
## Procédures de Rollback
### Rollback Phase 1 → Système fichier seul
**Situation :** MongoDB pose problème, il faut revenir au système fichier uniquement.
```bash
# 1. Désactiver la persistance MongoDB
FEATURE_EXCHANGE_PERSISTENCE=false
# 2. Redémarrer
docker compose restart app
# 3. Vérifier que les fichiers continuent d'être écrits
ls -la logs/tr069/
```
**Impact :** Les données déjà en MongoDB restent accessibles mais ne sont plus alimentées. Le SSE ne fonctionne plus. L'UI retombe sur les fichiers.
**Temps de rollback :** < 2 minutes (changement d'env + restart)
### Rollback Phase 2 → Phase 1 (Dual-Write)
**Situation :** L'API MongoDB a des problèmes, revenir au dual-write avec lecture fichier.
```bash
# 1. Changer la source API
FEATURE_EXCHANGE_API_SOURCE=file
# 2. Redémarrer
docker compose restart app
```
**Impact :** L'API lit les fichiers au lieu de MongoDB. Le dual-write continue. Aucune perte de données.
**Temps de rollback :** < 2 minutes
### Rollback Phase 3 → Phase 2
**Situation :** Après suppression du dual-write, MongoDB pose problème.
**⚠️ ATTENTION : Ce rollback nécessite un redéploiement du code.**
1. Revert du commit qui a supprimé le dual-write dans `CwmpLoggerMiddleware`
2. Redéployer l'application
3. Vérifier que les fichiers sont à nouveau écrits
```bash
# Identifier le commit de suppression du dual-write
git log --oneline -- app/middleware/cwmp_logger_middleware.ts
# Revert
git revert <commit-hash>
docker compose build app
docker compose up -d app
```
**Impact :** Nécessite un redéploiement (~5 minutes). Les échanges entre la suppression et le rollback ne sont PAS dans les fichiers.
**Temps de rollback :** 5-10 minutes
### Rollback SSE uniquement
**Situation :** Le SSE cause des problèmes (connexions, mémoire) mais MongoDB est stable.
```bash
# Désactiver uniquement le SSE
FEATURE_SSE_ENABLED=false
# Redémarrer
docker compose restart app
```
**Impact :** Le frontend bascule automatiquement en REST polling (toutes les 5 secondes). Les données MongoDB sont toujours disponibles. Pas de perte.
### Rollback complet (retour système fichier)
**Situation :** Problème majeur, tout désactiver.
```bash
# Tout désactiver
FEATURE_EXCHANGE_PERSISTENCE=false
FEATURE_SSE_ENABLED=false
FEATURE_EXCHANGE_API_SOURCE=file
# Redémarrer
docker compose restart app
```
**Impact :** Retour complet au système fichier. MongoDB n'est plus utilisé. L'UI affiche les logs comme avant la migration.
---
## Migration des données historiques (optionnel)
Si vous souhaitez importer les logs fichier existants dans MongoDB, utilisez le script ci-dessous. Cette étape est **optionnelle** car les anciens logs disparaîtront naturellement via le TTL 24h.
### Script d'import
```typescript
// scripts/migrate_file_logs_to_mongodb.ts
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { getMongoDb } from '#providers/mongodb_provider'
const LOG_DIR = join(process.cwd(), 'logs', 'tr069')
const SEPARATOR = '═'.repeat(80)
interface ParsedEntry {
deviceId: string
timestamp: string
direction: 'CPE→ACS' | 'ACS→CPE'
sessionId: string
rawXml: string
}
function parseLogFile(deviceId: string, content: string): ParsedEntry[] {
const entries: ParsedEntry[] = []
const blocks = content.split(SEPARATOR).filter((b) => b.trim())
for (let i = 0; i < blocks.length - 1; i += 2) {
const header = blocks[i].trim()
const xml = blocks[i + 1]?.trim() || ''
// Parse header: [2025-01-15T10:30:00.123Z] CPE→ACS | 10.0.0.1:54321->192.168.1.1:8080
const headerMatch = header.match(
/\[(.+?)\]\s+(CPE→ACS|ACS→CPE)\s+\|\s+(.+)/
)
if (!headerMatch) continue
entries.push({
deviceId,
timestamp: headerMatch[1],
direction: headerMatch[2] as 'CPE→ACS' | 'ACS→CPE',
sessionId: headerMatch[3],
rawXml: xml,
})
}
return entries
}
async function migrateAll(): Promise<void> {
const db = getMongoDb()
const collection = db.collection('cwmp_exchanges')
const counters = db.collection('cwmp_exchange_counters')
const files = readdirSync(LOG_DIR).filter((f) => f.endsWith('.log'))
let totalMigrated = 0
for (const file of files) {
const deviceId = file.replace('.log', '')
const content = readFileSync(join(LOG_DIR, file), 'utf-8')
const entries = parseLogFile(deviceId, content)
if (entries.length === 0) continue
// Get or initialize sequence counter
const counterResult = await counters.findOneAndUpdate(
{ _id: deviceId as any },
{ $inc: { seq: entries.length } },
{ upsert: true, returnDocument: 'after' }
)
const startSeq = (counterResult!.seq as number) - entries.length + 1
// Build documents
const docs = entries.map((entry, idx) => ({
...entry,
sequenceNumber: startSeq + idx,
cwmpMethod: extractMethod(entry.rawXml),
createdAt: new Date(entry.timestamp),
}))
// Bulk insert
await collection.insertMany(docs, { ordered: false }).catch((err) => {
console.warn(` ⚠️ Duplicates ignored for ${deviceId}: ${err.message}`)
})
totalMigrated += docs.length
console.log(` ✅ ${deviceId}: ${docs.length} échanges migrés`)
}
console.log(`\n📊 Total: ${totalMigrated} échanges migrés depuis ${files.length} fichiers`)
}
function extractMethod(xml: string): string {
if (!xml || xml.trim() === '') return 'Empty'
const match = xml.match(/<cwmp:(\w+)[\s/>]/)
return match ? match[1] : 'Unknown'
}
migrateAll().catch(console.error)
```
### Exécution
```bash
# Depuis la racine du projet
npx ts-node scripts/migrate_file_logs_to_mongodb.ts
```
**Notes :**
- Le script est idempotent : les doublons sont ignorés (grâce à l'index unique deviceId + sequenceNumber)
- Les documents importés auront un `createdAt` basé sur le timestamp du log, donc ils seront supprimés par le TTL 24h si ils datent de plus de 24 heures
- Pour conserver les données historiques plus longtemps, ajustez temporairement `EXCHANGE_STORE_TTL_SECONDS`
---
## Vérifications post-migration
### Checklist
| # | Vérification | Commande | Attendu |
|---|-------------|----------|---------|
| 1 | Collection existe | `db.cwmp_exchanges.stats()` | Collection présente |
| 2 | Index créés | `db.cwmp_exchanges.getIndexes()` | 4 index (dont _id) |
| 3 | TTL actif | `db.cwmp_exchanges.getIndexes()` | `expireAfterSeconds: 86400` |
| 4 | Compteurs fonctionnent | `db.cwmp_exchange_counters.find()` | Entrées par device |
| 5 | API accessible | `GET /api/v1/tr069-trace/:id/exchanges` | 200 avec données |
| 6 | SSE connecte | `GET /api/v1/tr069-trace/:id/exchanges/stream` | Event stream ouvert |
| 7 | Health check | `GET /api/v1/health/tr069-exchanges` | `status: "healthy"` |
### Commandes de diagnostic
```bash
# Statistiques collection
mongosh --eval "
const stats = db.cwmp_exchanges.stats()
print('Documents:', stats.count)
print('Taille:', (stats.size / 1024 / 1024).toFixed(2), 'Mo')
print('Index size:', (stats.totalIndexSize / 1024 / 1024).toFixed(2), 'Mo')
"
# Vérifier le TTL monitor
mongosh --eval "
db.cwmp_exchanges.aggregate([
{ \$group: { _id: null, oldest: { \$min: '\$createdAt' }, newest: { \$max: '\$createdAt' } } }
]).forEach(doc => {
print('Plus ancien:', doc.oldest)
print('Plus récent:', doc.newest)
const ageH = (Date.now() - new Date(doc.oldest).getTime()) / 3600000
print('Âge max:', ageH.toFixed(1), 'heures (devrait être < 24h)')
})
"
# Vérifier les compteurs de séquence
mongosh --eval "
db.cwmp_exchange_counters.find().sort({seq: -1}).limit(5).forEach(doc => {
print(doc._id + ': seq=' + doc.seq)
})
"
```
---
## Résumé des feature flags
| Flag | Phase 1 | Phase 2 | Phase 3 | Rollback |
|------|---------|---------|---------|----------|
| `FEATURE_EXCHANGE_PERSISTENCE` | `true` | `true` | `true` | `false` |
| `FEATURE_SSE_ENABLED` | `true` | `true` | `true` | `false` |
| `FEATURE_EXCHANGE_API_SOURCE` | `file` | `mongodb` | `mongodb` | `file` |
---
## FAQ
### Que se passe-t-il si MongoDB tombe pendant le dual-write ?
Les écritures MongoDB échouent silencieusement (fire-and-forget). Le système fichier continue normalement. Les messages critiques sont retentés 3 fois avec backoff exponentiel (1s, 2s, 4s) avant d'être abandonnés. La session CWMP n'est jamais interrompue.
### Le TTL va-t-il supprimer des données pendant la migration ?
Oui. Le TTL de 24h s'applique dès la création de l'index. Si vous importez des logs historiques de plus de 24h, ils seront supprimés par le TTL monitor MongoDB (qui tourne ~toutes les 60 secondes). Augmentez temporairement `EXCHANGE_STORE_TTL_SECONDS` si nécessaire.
### Puis-je migrer les données fichier vers MongoDB ?
Oui, voir la section "Migration des données historiques". C'est optionnel car le système fonctionne immédiatement avec les nouveaux échanges. Les anciens logs fichier restent lisibles via `FEATURE_EXCHANGE_API_SOURCE=file`.
### Combien d'espace MongoDB est nécessaire ?
Estimation : ~1 Ko par échange (métadonnées) + taille du XML brut (typiquement 2-50 Ko). Avec le cap de 5 000 messages par device et le TTL 24h, la croissance est bornée. Pour 100 devices tracés simultanément : ~500 Mo maximum.
### Le dual-write a-t-il un impact sur les performances CWMP ?
Non. Les écritures MongoDB sont fire-and-forget (`catch(() => {})`) et ne bloquent jamais le traitement CWMP. En cas de lenteur MongoDB, le middleware continue sans attendre.
+396
View File
@@ -0,0 +1,396 @@
# Alerting Rules — TR-069 Exchange Viewer
## Overview
Ce document décrit les règles d'alerte configurées pour le système TR-069 Exchange Viewer. Chaque règle définit un seuil critique, les conditions de déclenchement, l'impact attendu et la procédure d'escalade associée.
Les métriques sont exposées via le endpoint `/api/v1/health/tr069-exchanges` et les logs structurés JSON.
---
## Alert 1: SSE Active Connections > 100
### Seuil
| Métrique | Condition | Sévérité |
|----------|-----------|----------|
| `sse.active_connections` | > 100 connexions simultanées | **Warning** |
| `sse.active_connections` | > 200 connexions simultanées | **Critical** |
### Description
Un nombre élevé de connexions SSE simultanées indique une charge serveur importante. Chaque connexion SSE maintient un socket HTTP ouvert et un listener EventEmitter en mémoire. Au-delà de 100 connexions, le serveur risque une dégradation de performance (latence accrue, consommation mémoire excessive).
### Causes possibles
- Pic d'activité : de nombreux opérateurs tracent des devices simultanément
- Fuite de connexions : les clients ne ferment pas correctement les connexions EventSource (navigation sans cleanup)
- Reconnection storm : perte réseau transitoire provoquant la reconnexion simultanée de tous les clients
- Bug client : boucle de reconnexion infinie (EventSource retry sans backoff)
### Impact
- Augmentation de la consommation mémoire du processus Node.js
- Latence accrue sur les push SSE (chaque nouveau message est dispatché à N listeners)
- Risque d'épuisement des file descriptors (limite OS)
- Dégradation possible du traitement CWMP si le thread principal est saturé
### Détection
```json
// GET /api/v1/health/tr069-exchanges
{
"sse": {
"active_connections": 142,
"reconnections_1h": 56
}
}
```
Logs structurés :
```json
{ "level": "warn", "msg": "SSE connection count high", "active_connections": 142, "threshold": 100 }
```
### Procédure de résolution
1. **Vérifier** le nombre de connexions actives via le health check endpoint
2. **Identifier** les devices avec le plus de connexions (logs : `sse.connect` events par deviceId)
3. **Vérifier** si des connexions zombies existent (connexions ouvertes sans activité > 5 minutes)
4. **Appliquer** les timeouts serveur : les connexions inactives sont fermées après 5 min, les absolues après 2h
5. **Si le problème persiste** : redémarrer le service pour forcer la fermeture de toutes les connexions SSE
### Escalade
| Durée | Action |
|-------|--------|
| 0-5 min | Monitoring automatique, pas d'action |
| 5-15 min (Warning) | Vérifier les logs, identifier la cause |
| > 15 min (Warning) | Notifier l'équipe Dev via Slack #adam-acs-alerts |
| Immédiat (Critical > 200) | Notifier l'équipe Dev + Ops, envisager un restart |
| > 30 min (Critical) | Activer le feature flag `FEATURE_SSE_ENABLED=false` (fallback REST polling) |
---
## Alert 2: MongoDB Write Latency p95 > 100ms
### Seuil
| Métrique | Condition | Sévérité |
|----------|-----------|----------|
| `mongodb.write_latency_p95` | > 100ms | **Warning** |
| `mongodb.write_latency_p95` | > 500ms | **Critical** |
| `mongodb.write_latency_p99` | > 1000ms | **Critical** |
### Description
La latence d'écriture MongoDB au percentile 95 reflète la performance de persistance des échanges CWMP. Au-delà de 100ms (p95), les écritures commencent à s'accumuler, ce qui peut provoquer un retard dans la diffusion SSE et une pression mémoire sur le processus.
### Causes possibles
- Charge MongoDB élevée (autres opérations concurrentes, compaction, journalisation)
- Index manquant ou dégradé sur la collection `cwmp_exchanges`
- Disque I/O saturé sur le serveur MongoDB
- Collection `cwmp_exchanges` trop volumineuse (TTL cleanup en retard)
- Locks MongoDB : opérations bulk ou migrations en cours
### Impact
- Retard dans la diffusion SSE des nouveaux échanges (latence end-to-end augmentée)
- Activation des retries pour les messages critiques (backoff 1s, 2s, 4s)
- En cas de timeout persistant : perte potentielle de messages non-critiques (Empty, Unknown)
- Le traitement CWMP n'est jamais impacté (fire-and-forget)
### Détection
Logs structurés :
```json
{ "level": "warn", "msg": "MongoDB write slow", "collection": "cwmp_exchanges", "duration_ms": 145, "deviceId": "abc123", "cwmpMethod": "Inform" }
```
Métriques agrégées (via logs ou Prometheus) :
```
mongodb_write_latency_ms{collection="cwmp_exchanges", quantile="0.95"} 145
mongodb_write_latency_ms{collection="cwmp_exchanges", quantile="0.99"} 320
```
### Procédure de résolution
1. **Vérifier** l'état de MongoDB : `db.serverStatus()`, `db.currentOp()`
2. **Vérifier** les index de `cwmp_exchanges` :
```bash
mongosh --eval "db.cwmp_exchanges.getIndexes()"
```
3. **Vérifier** la taille de la collection et le nombre de documents :
```bash
mongosh --eval "db.cwmp_exchanges.stats()"
```
4. **Vérifier** le TTL cleanup (voir Alert 4)
5. **Si les index sont manquants** : recréer via la migration ou manuellement :
```bash
mongosh --eval "db.cwmp_exchanges.createIndex({deviceId:1, timestamp:1})"
mongosh --eval "db.cwmp_exchanges.createIndex({deviceId:1, sequenceNumber:1}, {unique:true})"
mongosh --eval "db.cwmp_exchanges.createIndex({createdAt:1}, {expireAfterSeconds:86400})"
```
6. **Si la charge est transitoire** : attendre la stabilisation (compaction terminée, etc.)
### Escalade
| Durée | Action |
|-------|--------|
| 0-5 min | Monitoring automatique |
| 5-15 min (Warning) | Vérifier les index et la charge MongoDB |
| > 15 min (Warning) | Notifier l'équipe Dev |
| Immédiat (Critical > 500ms) | Notifier Dev + DBA |
| > 30 min (Critical) | Envisager `FEATURE_EXCHANGE_PERSISTENCE=false` pour délester MongoDB |
---
## Alert 3: Message Loss Rate > 0.1%
### Seuil
| Métrique | Condition | Sévérité |
|----------|-----------|----------|
| `exchange.loss_rate` | > 0.1% (1 message sur 1000) | **Critical** |
| `exchange.loss_rate` | > 1.0% (1 message sur 100) | **Urgent** |
### Description
Le taux de perte de messages représente le pourcentage de messages CWMP capturés par le middleware mais non persistés en MongoDB (après épuisement des retries pour les critiques, ou discard immédiat pour les non-critiques). Un taux > 0.1% indique des défaillances critiques du backend de persistance.
### Causes possibles
- Défaillance MongoDB : connexion perdue, timeout, disque plein
- Retry exhaustion : 3 retries échoués consécutivement pour les messages critiques
- Erreur dans le service `ExchangeStoreService` (bug, exception non catchée)
- Saturation de la collection (cap à 5000 messages avec suppression simultanée)
- Problème réseau entre l'application et MongoDB
### Impact
- Trous dans l'historique des échanges visibles dans le Sequence Viewer
- Les opérateurs voient des séquences incomplètes, rendant le debug difficile
- Les messages critiques (Inform, SetParameterValues) perdus représentent une perte d'observabilité sur les actions CPE
### Détection
Logs structurés :
```json
{ "level": "error", "msg": "Exchange persistence failed after retries", "deviceId": "abc123", "cwmpMethod": "Inform", "retries": 3, "error": "MongoServerError: connection timeout" }
{ "level": "warn", "msg": "Non-critical exchange discarded", "deviceId": "abc123", "cwmpMethod": "Empty", "error": "MongoServerError: connection timeout" }
```
Calcul du taux de perte :
```
loss_rate = (messages_discarded / messages_captured) * 100
```
### Procédure de résolution
1. **Vérifier** la connectivité MongoDB : health check endpoint, `mongosh` ping
2. **Vérifier** les logs d'erreur pour identifier le pattern de défaillance
3. **Vérifier** l'espace disque disponible sur le serveur MongoDB
4. **Vérifier** que le TTL cleanup fonctionne (pas d'accumulation illimitée)
5. **Si MongoDB est injoignable** : vérifier le réseau, le service mongod, les limites de connexions
6. **Si MongoDB est surchargé** : identifier les opérations concurrentes, envisager un throttle
### Escalade
| Durée | Action |
|-------|--------|
| Immédiat (> 0.1%) | **Alerte critique** — Notifier l'équipe Dev immédiatement |
| 0-5 min | Diagnostic rapide : MongoDB up? Index OK? Disk space? |
| 5-15 min | Notifier Dev + Ops, vérifier infrastructure MongoDB |
| > 15 min | Escalade management, envisager rollback vers fichiers |
| Immédiat (> 1.0%) | **Alerte urgente** — Activer `FEATURE_EXCHANGE_PERSISTENCE=false`, diagnostic prioritaire |
---
## Alert 4: TTL Cleanup Lag > 1 Hour
### Seuil
| Métrique | Condition | Sévérité |
|----------|-----------|----------|
| `exchange.ttl_cleanup_lag` | > 1 heure | **Warning** |
| `exchange.ttl_cleanup_lag` | > 6 heures | **Critical** |
### Description
L'index TTL sur le champ `createdAt` (expire après 86400 secondes = 24h) est géré par un thread interne MongoDB qui s'exécute toutes les 60 secondes. Si le cleanup prend du retard, des documents expirés s'accumulent, augmentant la taille de la collection et dégradant les performances de requête.
Le "lag" est mesuré comme la différence entre le plus ancien document présent dans la collection et son heure d'expiration théorique (createdAt + 24h).
### Causes possibles
- MongoDB TTL monitor surchargé (collection très volumineuse, beaucoup de documents à supprimer)
- Index TTL supprimé ou corrompu
- MongoDB en mode de maintenance (locks, compaction)
- Charge élevée sur le serveur empêchant le thread TTL de s'exécuter
- Bug dans la configuration du TTL (mauvais champ, mauvaise valeur `expireAfterSeconds`)
### Impact
- Croissance non contrôlée de la collection `cwmp_exchanges`
- Dégradation progressive des performances de lecture (scans plus larges)
- Consommation disque accrue
- Potentiel dépassement des limites de stockage
### Détection
Vérification manuelle du lag :
```bash
# Trouver le document le plus ancien
mongosh --eval "
const oldest = db.cwmp_exchanges.find().sort({createdAt: 1}).limit(1).toArray()[0];
if (oldest) {
const expectedExpiry = new Date(oldest.createdAt.getTime() + 86400000);
const lag = Date.now() - expectedExpiry.getTime();
print('Oldest createdAt:', oldest.createdAt);
print('Expected expiry:', expectedExpiry);
print('Lag (ms):', Math.max(0, lag));
print('Lag (hours):', Math.max(0, lag / 3600000).toFixed(2));
} else {
print('Collection empty');
}
"
```
Vérification de l'index TTL :
```bash
mongosh --eval "
const indexes = db.cwmp_exchanges.getIndexes();
const ttlIndex = indexes.find(i => i.expireAfterSeconds !== undefined);
if (ttlIndex) {
print('TTL index found:', JSON.stringify(ttlIndex));
} else {
print('WARNING: No TTL index found!');
}
"
```
### Procédure de résolution
1. **Vérifier** que l'index TTL existe et est correctement configuré :
```bash
mongosh --eval "db.cwmp_exchanges.getIndexes()"
```
L'index attendu : `{ "createdAt": 1 }` avec `expireAfterSeconds: 86400`
2. **Si l'index TTL est manquant** — le recréer :
```bash
mongosh --eval "db.cwmp_exchanges.createIndex({createdAt: 1}, {expireAfterSeconds: 86400})"
```
3. **Si l'index existe mais le cleanup est en retard** :
- Vérifier la charge MongoDB : `db.serverStatus().metrics.ttl`
- Vérifier si un grand nombre de documents doivent être supprimés d'un coup
- Envisager un nettoyage manuel des plus anciens documents :
```bash
mongosh --eval "
const cutoff = new Date(Date.now() - 86400000);
const result = db.cwmp_exchanges.deleteMany({createdAt: {$lt: cutoff}});
print('Deleted:', result.deletedCount);
"
```
4. **Si MongoDB est en maintenance** : attendre la fin de l'opération, surveiller la reprise du TTL
### Escalade
| Durée | Action |
|-------|--------|
| 0-1h | Monitoring automatique (TTL thread cycle = 60s, léger retard normal) |
| 1-3h (Warning) | Vérifier l'existence de l'index TTL, vérifier la charge MongoDB |
| 3-6h (Warning) | Notifier l'équipe Dev, envisager un nettoyage manuel |
| > 6h (Critical) | Notifier Dev + DBA, nettoyage manuel obligatoire, diagnostic approfondi |
| > 24h | Escalade management — la collection grandit sans limite |
---
## Résumé des alertes
| # | Alerte | Seuil Warning | Seuil Critical | Métrique |
|---|--------|---------------|----------------|----------|
| 1 | SSE Active Connections | > 100 | > 200 | `sse.active_connections` |
| 2 | MongoDB Write Latency p95 | > 100ms | > 500ms | `mongodb.write_latency_p95` |
| 3 | Message Loss Rate | > 0.1% | > 1.0% | `exchange.loss_rate` |
| 4 | TTL Cleanup Lag | > 1 heure | > 6 heures | `exchange.ttl_cleanup_lag` |
---
## Configuration des alertes
### Variables d'environnement
| Variable | Défaut | Description |
|----------|--------|-------------|
| `ALERT_SSE_CONNECTIONS_WARNING` | `100` | Seuil warning connexions SSE |
| `ALERT_SSE_CONNECTIONS_CRITICAL` | `200` | Seuil critical connexions SSE |
| `ALERT_MONGO_LATENCY_P95_WARNING_MS` | `100` | Seuil warning latence MongoDB p95 (ms) |
| `ALERT_MONGO_LATENCY_P95_CRITICAL_MS` | `500` | Seuil critical latence MongoDB p95 (ms) |
| `ALERT_LOSS_RATE_CRITICAL_PCT` | `0.1` | Seuil critical taux de perte (%) |
| `ALERT_TTL_LAG_WARNING_HOURS` | `1` | Seuil warning lag TTL (heures) |
| `ALERT_TTL_LAG_CRITICAL_HOURS` | `6` | Seuil critical lag TTL (heures) |
### Intégration avec les systèmes d'alerte
Les alertes sont déclenchées via les logs structurés JSON, compatibles avec :
- **ELK Stack** : index pattern `adam-acs-*`, filtres sur `level: "error"` ou `level: "warn"` + tags spécifiques
- **Datadog** : log-based monitors avec conditions sur les champs JSON
- **Prometheus/Grafana** : si le endpoint `/metrics` est exposé, utiliser les alerting rules Prometheus
Exemple de règle Prometheus :
```yaml
groups:
- name: tr069-exchange-viewer
rules:
- alert: HighSSEConnections
expr: adam_acs_sse_active_connections > 100
for: 5m
labels:
severity: warning
annotations:
summary: "High SSE connection count"
description: "{{ $value }} SSE connections active (threshold: 100)"
- alert: MongoWriteLatencyHigh
expr: histogram_quantile(0.95, adam_acs_mongodb_write_duration_seconds_bucket{collection="cwmp_exchanges"}) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "MongoDB write latency p95 > 100ms"
description: "p95 latency: {{ $value }}s for cwmp_exchanges writes"
- alert: ExchangeMessageLoss
expr: rate(adam_acs_exchange_messages_lost_total[5m]) / rate(adam_acs_exchange_messages_captured_total[5m]) > 0.001
for: 1m
labels:
severity: critical
annotations:
summary: "Message loss rate exceeds 0.1%"
description: "{{ $value | humanizePercentage }} messages lost in last 5 minutes"
- alert: TTLCleanupLag
expr: adam_acs_exchange_ttl_lag_seconds > 3600
for: 10m
labels:
severity: warning
annotations:
summary: "TTL cleanup lag exceeds 1 hour"
description: "Oldest expired document is {{ $value | humanizeDuration }} past its TTL"
```
---
## Contacts d'escalade
| Niveau | Contact | Canal |
|--------|---------|-------|
| L1 — Warning | Équipe Dev adam-acs | Slack #adam-acs-alerts |
| L2 — Critical | Lead Dev + Ops | Slack #adam-acs-alerts + mention @oncall |
| L3 — Urgent/Prolongé | Management technique | Escalade téléphonique |
| DBA (MongoDB) | Équipe DBA | Slack #dba-support |
+180
View File
@@ -0,0 +1,180 @@
---
name: Precision Protocol System
colors:
surface: '#f8f9ff'
surface-dim: '#cbdbf5'
surface-bright: '#f8f9ff'
surface-container-lowest: '#ffffff'
surface-container-low: '#eff4ff'
surface-container: '#e5eeff'
surface-container-high: '#dce9ff'
surface-container-highest: '#d3e4fe'
on-surface: '#0b1c30'
on-surface-variant: '#45464d'
inverse-surface: '#213145'
inverse-on-surface: '#eaf1ff'
outline: '#76777d'
outline-variant: '#c6c6cd'
surface-tint: '#565e74'
primary: '#000000'
on-primary: '#ffffff'
primary-container: '#131b2e'
on-primary-container: '#7c839b'
inverse-primary: '#bec6e0'
secondary: '#006a61'
on-secondary: '#ffffff'
secondary-container: '#86f2e4'
on-secondary-container: '#006f66'
tertiary: '#000000'
on-tertiary: '#ffffff'
tertiary-container: '#0f0069'
on-tertiary-container: '#7671ff'
error: '#ba1a1a'
on-error: '#ffffff'
error-container: '#ffdad6'
on-error-container: '#93000a'
primary-fixed: '#dae2fd'
primary-fixed-dim: '#bec6e0'
on-primary-fixed: '#131b2e'
on-primary-fixed-variant: '#3f465c'
secondary-fixed: '#89f5e7'
secondary-fixed-dim: '#6bd8cb'
on-secondary-fixed: '#00201d'
on-secondary-fixed-variant: '#005049'
tertiary-fixed: '#e2dfff'
tertiary-fixed-dim: '#c3c0ff'
on-tertiary-fixed: '#0f0069'
on-tertiary-fixed-variant: '#3323cc'
background: '#f8f9ff'
on-background: '#0b1c30'
surface-variant: '#d3e4fe'
typography:
headline-lg:
fontFamily: Inter
fontSize: 30px
fontWeight: '600'
lineHeight: 38px
letterSpacing: -0.02em
headline-md:
fontFamily: Inter
fontSize: 24px
fontWeight: '600'
lineHeight: 32px
letterSpacing: -0.01em
headline-sm:
fontFamily: Inter
fontSize: 18px
fontWeight: '600'
lineHeight: 26px
body-lg:
fontFamily: Inter
fontSize: 16px
fontWeight: '400'
lineHeight: 24px
body-md:
fontFamily: Inter
fontSize: 14px
fontWeight: '400'
lineHeight: 20px
body-sm:
fontFamily: Inter
fontSize: 13px
fontWeight: '400'
lineHeight: 18px
label-md:
fontFamily: Inter
fontSize: 12px
fontWeight: '500'
lineHeight: 16px
letterSpacing: 0.05em
mono-md:
fontFamily: JetBrains Mono
fontSize: 13px
fontWeight: '400'
lineHeight: 20px
mono-sm:
fontFamily: JetBrains Mono
fontSize: 11px
fontWeight: '400'
lineHeight: 16px
rounded:
sm: 0.125rem
DEFAULT: 0.25rem
md: 0.375rem
lg: 0.5rem
xl: 0.75rem
full: 9999px
spacing:
unit: 4px
container-padding: 24px
gutter: 16px
sidebar-width: 260px
stack-sm: 8px
stack-md: 16px
stack-lg: 32px
---
## Brand & Style
This design system is engineered for high-performance network analysis and protocol monitoring. The brand personality is rooted in **precision, neutrality, and clarity**. It avoids decorative flourishes to prioritize the rapid interpretation of complex data structures.
The visual style is a blend of **Minimalism** and **Modern Corporate** aesthetics. It utilizes a spacious layout to reduce the cognitive load inherent in dense data environments. The interface conveys reliability through a systematic application of subtle borders and high-contrast typography, ensuring that critical network events are immediately visible.
## Colors
The palette is anchored in **Slate and Zinc** neutrals to provide a professional, calm environment for long-term monitoring.
* **Primary (Slate 950):** Used for core navigation, high-level headers, and text to ensure maximum contrast.
* **Neutral (Slate/Zinc):** Used for structural elements, secondary text, and inactive states.
* **Flow Accents:** Semantic colors are reserved for directional data. **Teal (ACS -> CPE)** and **Indigo (CPE -> ACS)** allow engineers to visually parse the direction of TR-069 traffic at a glance without reading text labels.
* **Success/Error:** Traditional Green and Red are used sparingly for status indicators and connection states.
## Typography
This design system utilizes a dual-font strategy. **Inter** is the workhorse for the UI, chosen for its exceptional legibility in small sizes and high-density layouts. **JetBrains Mono** is utilized exclusively for technical data, including XML, JSON payloads, and packet hex dumps.
Headlines use tighter letter spacing for a more authoritative look, while labels utilize uppercase styling and increased tracking for clear categorization in sidebars and table headers. Monospaced text is rendered with a slightly smaller font size than body text to accommodate long lines of code without excessive wrapping.
## Layout & Spacing
The layout follows a **Hybrid Grid** model. Navigation and property inspectors use fixed-width sidebars, while the main content area—typically containing packet logs or traffic graphs—uses a **Fluid Grid** to maximize horizontal real estate.
### Breakpoints
* **Desktop (1280px+):** 12-column fluid grid, 24px margins.
* **Tablet (768px - 1279px):** 8-column fluid grid, 16px margins. Sidebar collapses to icons.
* **Mobile (<767px):** 4-column fluid grid, 12px margins. Horizontal scrolling enabled for data tables.
Spacing is based on a **4px base unit**. Component internals use 8px or 12px increments to maintain a compact, "instrumental" feel without appearing cluttered.
## Elevation & Depth
To maintain a technical and minimalist aesthetic, this design system avoids heavy shadows. Instead, it relies on **Tonal Layers** and **Low-Contrast Outlines**.
* **Background (Level 0):** Slate-50. The base canvas.
* **Surface (Level 1):** White. Primary cards and panels.
* **Active/Overlay (Level 2):** White with a subtle 1px border in Slate-200 and a very soft, high-diffusion shadow (4px blur, 2% opacity) to indicate temporary elements like tooltips or dropdowns.
Visual separation between adjacent panels is achieved via 1px solid borders in Slate-200 rather than elevation, ensuring the UI remains crisp and "flat."
## Shapes
The shape language is **Soft (Level 1)**. A 4px radius is applied to buttons, input fields, and panels. This subtle rounding provides a modern touch without sacrificing the professional, rigid structure required for a technical tool.
Status indicators (pills) use a slightly larger 12px radius to distinguish them from interactive buttons, while data visualization bars use sharp 2px corners for maximum precision.
## Components
### Buttons
Primary buttons use the Slate-950 background with white text. Ghost buttons (transparent background with Slate-200 borders) are preferred for secondary actions to keep the focus on the data.
### Packet Log Entries
Logs must use a zebra-stripe pattern (White / Slate-50) for horizontal tracking. The "Flow Direction" is indicated by a 4px vertical accent bar on the far left of the row (Teal or Indigo).
### Input Fields
Fields use a 1px Slate-200 border. On focus, the border transitions to Slate-900. Labels are always positioned above the input in the `label-md` style for clarity.
### Data Displays (Code Blocks)
XML/JSON payloads are wrapped in containers with a Slate-50 background and a 1px border. They use `mono-md` typography with syntax highlighting that mirrors the flow colors (Teal for tags, Indigo for values).
### Chips & Badges
Used for protocol methods (e.g., `Inform`, `GetParameterValues`). These are small, low-contrast components (Slate-100 background) to avoid competing with the flow direction accents.
+367
View File
@@ -0,0 +1,367 @@
<!DOCTYPE html>
<html class="light" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>TR-069 Protocol Analyzer</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500&amp;family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&amp;display=swap" rel="stylesheet"/>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
vertical-align: middle;
}
/* Custom scrollbar for technical look */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
.lifeline-gradient {
background: linear-gradient(to bottom, #000 0%, #000 5%, transparent 5%, transparent 100%);
background-size: 1px 20px;
}
</style>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"tertiary-fixed-dim": "#c3c0ff",
"surface-container-high": "#dce9ff",
"surface-container-lowest": "#ffffff",
"on-background": "#0b1c30",
"on-tertiary": "#ffffff",
"error": "#ba1a1a",
"on-primary-fixed": "#131b2e",
"surface-tint": "#565e74",
"secondary-fixed-dim": "#6bd8cb",
"secondary-container": "#86f2e4",
"surface-container": "#e5eeff",
"on-primary-fixed-variant": "#3f465c",
"outline-variant": "#c6c6cd",
"on-secondary-fixed": "#00201d",
"on-surface-variant": "#45464d",
"surface-bright": "#f8f9ff",
"on-primary": "#ffffff",
"tertiary": "#000000",
"primary-fixed-dim": "#bec6e0",
"on-tertiary-container": "#7671ff",
"surface-container-low": "#eff4ff",
"surface-container-highest": "#d3e4fe",
"surface-variant": "#d3e4fe",
"inverse-on-surface": "#eaf1ff",
"secondary": "#006a61",
"primary-fixed": "#dae2fd",
"primary": "#000000",
"tertiary-container": "#0f0069",
"on-error-container": "#93000a",
"on-tertiary-fixed-variant": "#3323cc",
"on-surface": "#0b1c30",
"on-error": "#ffffff",
"on-secondary": "#ffffff",
"surface-dim": "#cbdbf5",
"error-container": "#ffdad6",
"tertiary-fixed": "#e2dfff",
"secondary-fixed": "#89f5e7",
"background": "#f8f9ff",
"primary-container": "#131b2e",
"on-secondary-fixed-variant": "#005049",
"on-tertiary-fixed": "#0f0069",
"on-secondary-container": "#006f66",
"surface": "#f8f9ff",
"inverse-surface": "#213145",
"inverse-primary": "#bec6e0",
"outline": "#76777d"
},
"borderRadius": {
"DEFAULT": "0.125rem",
"lg": "0.25rem",
"xl": "0.5rem",
"full": "0.75rem"
},
"spacing": {
"gutter": "16px",
"stack-md": "16px",
"stack-sm": "8px",
"unit": "4px",
"stack-lg": "32px",
"sidebar-width": "260px",
"container-padding": "24px"
},
"fontFamily": {
"mono-sm": ["JetBrains Mono"],
"headline-md": ["Inter"],
"mono-md": ["JetBrains Mono"],
"headline-lg": ["Inter"],
"body-md": ["Inter"],
"body-sm": ["Inter"],
"body-lg": ["Inter"],
"label-md": ["Inter"],
"headline-sm": ["Inter"]
},
"fontSize": {
"mono-sm": ["11px", { "lineHeight": "16px", "fontWeight": "400" }],
"headline-md": ["24px", { "lineHeight": "32px", "letterSpacing": "-0.01em", "fontWeight": "600" }],
"mono-md": ["13px", { "lineHeight": "20px", "fontWeight": "400" }],
"headline-lg": ["30px", { "lineHeight": "38px", "letterSpacing": "-0.02em", "fontWeight": "600" }],
"body-md": ["14px", { "lineHeight": "20px", "fontWeight": "400" }],
"body-sm": ["13px", { "lineHeight": "18px", "fontWeight": "400" }],
"body-lg": ["16px", { "lineHeight": "24px", "fontWeight": "400" }],
"label-md": ["12px", { "lineHeight": "16px", "letterSpacing": "0.05em", "fontWeight": "500" }],
"headline-sm": ["18px", { "lineHeight": "26px", "fontWeight": "600" }]
}
},
},
}
</script>
</head>
<body class="bg-background text-on-background font-body-md overflow-hidden">
<!-- Top Navigation Shell -->
<header class="bg-surface text-on-surface font-headline-sm text-headline-sm docked full-width top-0 border-b border-outline-variant flex justify-between items-center px-gutter w-full h-16 z-50">
<div class="flex items-center gap-stack-lg">
<span class="font-headline-sm text-headline-sm font-bold text-on-surface">TR-069 Protocol Analyzer</span>
<nav class="hidden md:flex items-center h-16 gap-6">
<a class="text-on-surface-variant font-label-md text-label-md h-full flex items-center px-2 hover:bg-surface-container transition-colors duration-200" href="#">Dashboard</a>
<a class="text-on-surface-variant font-label-md text-label-md h-full flex items-center px-2 hover:bg-surface-container transition-colors duration-200" href="#">Live Stream</a>
<a class="text-primary font-bold border-b-2 border-primary h-full flex items-center px-2" href="#">Analysis</a>
<a class="text-on-surface-variant font-label-md text-label-md h-full flex items-center px-2 hover:bg-surface-container transition-colors duration-200" href="#">Config</a>
</nav>
</div>
<div class="flex items-center gap-4">
<div class="relative group">
<span class="material-symbols-outlined cursor-pointer p-2 hover:bg-surface-container-high rounded-full transition-colors duration-150" data-icon="filter_list">filter_list</span>
</div>
<span class="material-symbols-outlined cursor-pointer p-2 hover:bg-surface-container-high rounded-full transition-colors duration-150" data-icon="settings">settings</span>
<span class="material-symbols-outlined cursor-pointer p-2 hover:bg-surface-container-high rounded-full transition-colors duration-150" data-icon="help">help</span>
</div>
</header>
<div class="flex h-[calc(100vh-64px)] overflow-hidden">
<!-- Side Navigation Shell -->
<aside class="bg-surface-container-low text-primary font-label-md text-label-md docked left-0 h-full w-sidebar-width border-r border-outline-variant flex flex-col sticky top-0">
<div class="p-6">
<div class="flex items-center gap-3 mb-8">
<div class="w-10 h-10 rounded-full bg-primary-container flex items-center justify-center">
<span class="material-symbols-outlined text-surface" data-icon="router">router</span>
</div>
<div>
<div class="font-headline-sm text-[14px] font-bold text-primary">Network Monitor</div>
<div class="text-[11px] text-on-surface-variant">Active Nodes: 1,240</div>
</div>
</div>
<button class="w-full bg-primary text-on-primary py-2.5 rounded-lg font-label-md text-label-md mb-8 hover:opacity-90 transition-opacity">
New Capture
</button>
<nav class="space-y-1">
<a class="flex items-center gap-3 px-4 py-3 bg-secondary-container text-on-secondary-container font-semibold rounded-lg transition-all duration-150" href="#">
<span class="material-symbols-outlined" data-icon="history">history</span>
<span>Sessions</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 text-on-surface-variant hover:bg-surface-container-high rounded-lg transition-all duration-150" href="#">
<span class="material-symbols-outlined" data-icon="router">router</span>
<span>Devices</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 text-on-surface-variant hover:bg-surface-container-high rounded-lg transition-all duration-150" href="#">
<span class="material-symbols-outlined" data-icon="analytics">analytics</span>
<span>Diagnostics</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 text-on-surface-variant hover:bg-surface-container-high rounded-lg transition-all duration-150" href="#">
<span class="material-symbols-outlined" data-icon="list_alt">list_alt</span>
<span>Logs</span>
</a>
</nav>
</div>
<div class="mt-auto p-6 border-t border-outline-variant">
<a class="flex items-center gap-3 px-4 py-3 text-on-surface-variant hover:bg-surface-container-high rounded-lg transition-all duration-150" href="#">
<span class="material-symbols-outlined" data-icon="logout">logout</span>
<span>Logout</span>
</a>
</div>
</aside>
<!-- Main Content Area -->
<main class="flex-1 flex flex-col overflow-hidden bg-white">
<!-- Session Status Header -->
<div class="flex items-center justify-between px-8 py-4 bg-surface-container-low border-b border-outline-variant">
<div class="flex gap-8">
<div>
<span class="text-label-md font-label-md text-on-surface-variant block uppercase tracking-wider mb-1">Device Serial</span>
<span class="text-body-md font-mono-md font-medium text-on-surface">TR069-NODE-550291-B</span>
</div>
<div class="h-10 w-px bg-outline-variant"></div>
<div>
<span class="text-label-md font-label-md text-on-surface-variant block uppercase tracking-wider mb-1">IP Address</span>
<span class="text-body-md font-mono-md font-medium text-on-surface">192.168.100.42</span>
</div>
<div class="h-10 w-px bg-outline-variant"></div>
<div>
<span class="text-label-md font-label-md text-on-surface-variant block uppercase tracking-wider mb-1">Connection Status</span>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-secondary"></span>
<span class="text-body-md font-medium text-on-surface">CWMP Session Established</span>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="px-3 py-1 bg-surface-container text-on-surface-variant rounded-full text-label-md font-label-md border border-outline-variant">Session ID: 88241</span>
<button class="material-symbols-outlined text-on-surface-variant p-1" data-icon="more_vert">more_vert</button>
</div>
</div>
<!-- Sequence Diagram Content -->
<div class="flex-1 flex overflow-hidden">
<div class="flex-1 overflow-y-auto relative bg-slate-50 p-12">
<!-- Lifeline Headers -->
<div class="sticky top-0 z-10 flex justify-around mb-12 pointer-events-none">
<div class="px-10 py-3 bg-primary text-on-primary rounded font-bold shadow-sm border border-primary min-w-[120px] text-center">CPE</div>
<div class="px-10 py-3 bg-primary text-on-primary rounded font-bold shadow-sm border border-primary min-w-[120px] text-center">ACS</div>
</div>
<!-- Vertical Lifelines -->
<div class="absolute top-0 bottom-0 left-1/4 w-px bg-outline pointer-events-none opacity-20"></div>
<div class="absolute top-0 bottom-0 right-1/4 w-px bg-outline pointer-events-none opacity-20"></div>
<!-- Messages -->
<div class="relative space-y-10 pb-20">
<!-- Row: Inform -->
<div class="relative group cursor-pointer" onclick="selectMessage('inform')">
<div class="absolute left-[25%] right-[25%] flex items-center">
<div class="flex-1 h-px bg-secondary relative">
<div class="absolute right-0 -top-[5px] border-y-[6px] border-y-transparent border-l-[10px] border-l-secondary"></div>
</div>
<div class="absolute left-1/2 -translate-x-1/2 -top-6 text-center">
<span class="px-2 py-0.5 bg-secondary-container text-on-secondary-container text-label-md font-label-md rounded border border-secondary/20">Inform</span>
<span class="block text-[10px] font-mono-sm text-on-surface-variant mt-1">10:05:22.450</span>
</div>
</div>
<div class="h-8 invisible">spacer</div>
</div>
<!-- Row: InformResponse -->
<div class="relative group cursor-pointer" onclick="selectMessage('inform-response')">
<div class="absolute left-[25%] right-[25%] flex items-center">
<div class="flex-1 h-px bg-tertiary-container relative">
<div class="absolute left-0 -top-[5px] border-y-[6px] border-y-transparent border-r-[10px] border-r-tertiary-container"></div>
</div>
<div class="absolute left-1/2 -translate-x-1/2 -top-6 text-center">
<span class="px-2 py-0.5 bg-surface-container-high text-on-tertiary-fixed-variant text-label-md font-label-md rounded border border-outline">InformResponse</span>
<span class="block text-[10px] font-mono-sm text-on-surface-variant mt-1">10:05:22.612</span>
</div>
</div>
<div class="h-8 invisible">spacer</div>
</div>
<!-- Selected Row: GetParameterValues -->
<div class="relative group cursor-pointer" onclick="selectMessage('get-params')">
<!-- Selection Highlight -->
<div class="absolute -left-12 -right-12 h-16 bg-surface-container border-y border-outline-variant -mt-4 opacity-50 z-0"></div>
<div class="absolute left-[25%] right-[25%] flex items-center z-10">
<div class="flex-1 h-[2px] bg-primary relative">
<div class="absolute left-0 -top-[6px] border-y-[7px] border-y-transparent border-r-[12px] border-r-primary"></div>
</div>
<div class="absolute left-1/2 -translate-x-1/2 -top-6 text-center">
<span class="px-2 py-0.5 bg-primary text-on-primary text-label-md font-label-md rounded shadow-sm">GetParameterValues</span>
<span class="block text-[10px] font-mono-sm font-bold text-primary mt-1">10:05:23.002</span>
</div>
</div>
<div class="h-8 invisible">spacer</div>
</div>
<!-- Row: GetParameterValuesResponse -->
<div class="relative group cursor-pointer" onclick="selectMessage('get-params-response')">
<div class="absolute left-[25%] right-[25%] flex items-center">
<div class="flex-1 h-px bg-secondary relative">
<div class="absolute right-0 -top-[5px] border-y-[6px] border-y-transparent border-l-[10px] border-l-secondary"></div>
</div>
<div class="absolute left-1/2 -translate-x-1/2 -top-6 text-center">
<span class="px-2 py-0.5 bg-secondary-container text-on-secondary-container text-label-md font-label-md rounded border border-secondary/20">GetParameterValuesResponse</span>
<span class="block text-[10px] font-mono-sm text-on-surface-variant mt-1">10:05:23.189</span>
</div>
</div>
<div class="h-8 invisible">spacer</div>
</div>
<!-- Row: SetParameterValues -->
<div class="relative group cursor-pointer">
<div class="absolute left-[25%] right-[25%] flex items-center">
<div class="flex-1 h-px bg-tertiary-container relative">
<div class="absolute left-0 -top-[5px] border-y-[6px] border-y-transparent border-r-[10px] border-r-tertiary-container"></div>
</div>
<div class="absolute left-1/2 -translate-x-1/2 -top-6 text-center">
<span class="px-2 py-0.5 bg-surface-container-high text-on-tertiary-fixed-variant text-label-md font-label-md rounded border border-outline">SetParameterValues</span>
<span class="block text-[10px] font-mono-sm text-on-surface-variant mt-1">10:05:23.441</span>
</div>
</div>
<div class="h-8 invisible">spacer</div>
</div>
</div>
</div>
<!-- Inspector Panel (Right) -->
<div class="w-[450px] bg-white border-l border-outline-variant flex flex-col shadow-xl z-20">
<div class="p-4 border-b border-outline-variant bg-surface flex justify-between items-center">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-on-surface-variant" data-icon="description">description</span>
<span class="font-headline-sm text-[16px] font-bold text-on-surface">Message Detail</span>
</div>
<div class="flex gap-2">
<button class="material-symbols-outlined text-on-surface-variant hover:text-primary transition-colors" data-icon="content_copy">content_copy</button>
<button class="material-symbols-outlined text-on-surface-variant hover:text-primary transition-colors" data-icon="close">close</button>
</div>
</div>
<div class="flex-1 overflow-auto bg-surface-container-low p-4">
<div class="flex items-center gap-2 mb-4">
<span class="text-label-md font-label-md bg-primary-container text-on-primary-container px-2 py-1 rounded">CWMP:GetParameterValues</span>
<span class="text-mono-sm text-on-surface-variant">ID: 1024_A</span>
</div>
<!-- Code Block Display -->
<div class="rounded-lg border border-outline-variant bg-white p-4 font-mono-md text-mono-md leading-relaxed shadow-inner overflow-hidden">
<pre class="whitespace-pre-wrap"><span class="text-secondary">&lt;soap:Envelope</span> <span class="text-tertiary-fixed-variant">xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"</span><span class="text-secondary">&gt;</span>
<span class="text-secondary">&lt;soap:Header&gt;</span>
<span class="text-secondary">&lt;cwmp:ID</span> <span class="text-tertiary-fixed-variant">soap:mustUnderstand="1"</span><span class="text-secondary">&gt;</span>1024_A<span class="text-secondary">&lt;/cwmp:ID&gt;</span>
<span class="text-secondary">&lt;/soap:Header&gt;</span>
<span class="text-secondary">&lt;soap:Body&gt;</span>
<span class="text-secondary">&lt;cwmp:GetParameterValues&gt;</span>
<span class="text-secondary">&lt;ParameterNames</span> <span class="text-tertiary-fixed-variant">soap:arrayType="xsd:string[3]"</span><span class="text-secondary">&gt;</span>
<span class="text-secondary">&lt;string&gt;</span>InternetGatewayDevice.DeviceInfo.HardwareVersion<span class="text-secondary">&lt;/string&gt;</span>
<span class="text-secondary">&lt;string&gt;</span>InternetGatewayDevice.DeviceInfo.SoftwareVersion<span class="text-secondary">&lt;/string&gt;</span>
<span class="text-secondary">&lt;string&gt;</span>InternetGatewayDevice.ManagementServer.ConnectionRequestURL<span class="text-secondary">&lt;/string&gt;</span>
<span class="text-secondary">&lt;/ParameterNames&gt;</span>
<span class="text-secondary">&lt;/cwmp:GetParameterValues&gt;</span>
<span class="text-secondary">&lt;/soap:Body&gt;</span>
<span class="text-secondary">&lt;/soap:Envelope&gt;</span></pre>
</div>
<div class="mt-6 space-y-4">
<h4 class="text-label-md font-label-md text-on-surface-variant uppercase tracking-wider">Analysis Summary</h4>
<div class="p-3 bg-secondary-container/20 border-l-4 border-secondary rounded-r">
<p class="text-body-sm text-on-surface">ACS is requesting device hardware and software versioning information to determine compatible firmware upgrade paths.</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="bg-white p-3 border border-outline-variant rounded">
<span class="text-label-md font-label-md text-on-surface-variant block mb-1">MTU Size</span>
<span class="text-body-md font-medium">1500 Bytes</span>
</div>
<div class="bg-white p-3 border border-outline-variant rounded">
<span class="text-label-md font-label-md text-on-surface-variant block mb-1">Latency</span>
<span class="text-body-md font-medium text-secondary">42ms</span>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Micro-interaction script -->
<script>
function selectMessage(id) {
// Placeholder logic for visual feedback in a prototype
console.log('Selected message context:', id);
}
// Add subtle hover tracking for interactive feel
document.querySelectorAll('.group').forEach(item => {
item.addEventListener('mouseenter', () => {
item.style.transform = 'translateX(2px)';
item.style.transition = 'transform 0.1s ease-out';
});
item.addEventListener('mouseleave', () => {
item.style.transform = 'translateX(0)';
});
});
</script>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

@@ -0,0 +1,486 @@
# Troubleshooting Guide: TR-069 Exchange Viewer
Ce guide documente les problèmes courants du système de visualisation des échanges TR-069/CWMP, leurs causes et solutions.
## Table des matières
1. [Déconnexions SSE](#1-déconnexions-sse)
2. [Timeouts MongoDB et récupération](#2-timeouts-mongodb-et-récupération)
3. [Diagnostic de perte de messages](#3-diagnostic-de-perte-de-messages)
4. [Vérification des index et du TTL](#4-vérification-des-index-et-du-ttl)
5. [Problèmes frontend (SequenceViewer)](#5-problèmes-frontend-sequenceviewer)
6. [Feature flags et rollback](#6-feature-flags-et-rollback)
---
## 1. Déconnexions SSE
### 1.1 Causes courantes
| Cause | Symptôme | Fréquence |
|-------|----------|-----------|
| **Timeout d'inactivité (5 min)** | Déconnexion après 5 minutes sans nouvel échange | Normal — par design |
| **Timeout absolu (2 h)** | Déconnexion après 2 heures de connexion continue | Normal — par design |
| **Proxy/reverse-proxy buffering** | Messages SSE arrivent en burst au lieu de temps réel | Courant avec nginx |
| **Perte réseau** | Indicateur "disconnected" dans le viewer | Variable |
| **SSE désactivé (feature flag)** | Erreur 503 à la connexion SSE | Intentionnel |
| **Device non tracé** | Erreur 404 à la connexion SSE | Erreur utilisateur |
### 1.2 Solutions
#### Timeout d'inactivité / absolu
Le système est conçu pour fermer les connexions inactives. Le client se reconnecte automatiquement via le mécanisme `Last-Event-ID` — aucun message n'est perdu.
Pour ajuster les timeouts :
```bash
# Augmenter le timeout d'inactivité (défaut: 300000ms = 5 min)
EXCHANGE_STORE_SSE_INACTIVITY_TIMEOUT_MS=600000
# Augmenter le timeout absolu (défaut: 7200000ms = 2h)
EXCHANGE_STORE_SSE_ABSOLUTE_TIMEOUT_MS=14400000
```
#### Proxy/reverse-proxy buffering
Si un reverse-proxy (nginx, HAProxy) est en place, il peut bufferiser les réponses SSE. Le header `X-Accel-Buffering: no` est déjà envoyé par le serveur, mais vérifiez aussi la configuration nginx :
```nginx
location /api/v1/tr069-trace/ {
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
}
```
#### Client qui ne se reconnecte pas
Le composable `useExchangeStream` utilise l'`EventSource` natif avec retry automatique (3 secondes par défaut). Si la reconnexion ne fonctionne pas :
1. Vérifiez que le device est toujours en trace active
2. Vérifiez les logs serveur pour des erreurs 404 ou 503
3. Vérifiez la valeur `retry` dans la configuration SSE :
```bash
# Intervalle de reconnexion envoyé au client (défaut: 3000ms)
EXCHANGE_STORE_SSE_RETRY_INTERVAL_MS=3000
```
### 1.3 Diagnostic dans les logs
Cherchez les entrées `[SSE]` dans les logs applicatifs :
```bash
docker compose logs app | grep '\[SSE\]'
```
Messages attendus :
- `[SSE] Connection closed { deviceId: "...", reason: "inactivity" }` — timeout normal
- `[SSE] Connection closed { deviceId: "...", reason: "absolute" }` — timeout absolu normal
- `[SSE] Connection closed { deviceId: "...", reason: "client_disconnect" }` — client a fermé
- `[SSE] Error fetching missed exchanges for reconnection` — problème MongoDB lors du rattrapage
---
## 2. Timeouts MongoDB et récupération
### 2.1 Scénarios de timeout
| Scénario | Impact | Comportement |
|----------|--------|--------------|
| **Write timeout (persistance)** | Message potentiellement perdu | Retry (critique) ou discard (non-critique) |
| **Read timeout (API >5s)** | Résultats partiels retournés | `isTruncated: true` dans la réponse |
| **Connection lost** | Toutes les opérations échouent | Les sessions CWMP continuent sans log |
### 2.2 Write timeout — Retry automatique
Le `ExchangeStoreService` gère les échecs d'écriture selon la criticité du message :
**Messages critiques** (Inform, GetParameterValues, SetParameterValuesResponse, Fault, etc.) :
- 3 tentatives avec backoff exponentiel : 1s, 2s, 4s
- Si toutes échouent : log error + discard (session CWMP non impactée)
**Messages non-critiques** (Empty, Unknown) :
- Aucun retry — discard immédiat + log warning
Vérifier les échecs de retry :
```bash
docker compose logs app | grep '\[ExchangeStore\] All retries exhausted'
docker compose logs app | grep '\[ExchangeStore\] Critical message persistence failed'
```
Pour ajuster la politique de retry :
```bash
# Nombre max de tentatives (défaut: 3)
EXCHANGE_STORE_RETRY_MAX_ATTEMPTS=3
# Délai de base en ms pour le backoff (défaut: 1000)
# Résultat : 1000ms, 2000ms, 4000ms
EXCHANGE_STORE_RETRY_BASE_DELAY_MS=1000
```
### 2.3 Read timeout — Résultats partiels
Si une requête API dépasse 5 secondes, le contrôleur retourne les résultats partiels avec un flag :
```json
{
"exchanges": [...],
"metadata": {
"isTruncated": true,
"total": 4500
}
}
```
**Causes courantes :**
- Index manquant sur `(deviceId, timestamp)` — voir section 4
- Trop de documents pour le device (proche du cap 5000)
- Charge MongoDB élevée (vérifier les métriques)
Pour ajuster le timeout de lecture :
```bash
# Timeout en ms (défaut: 5000)
EXCHANGE_STORE_READ_TIMEOUT_MS=5000
```
### 2.4 Vérifier la connectivité MongoDB
```bash
# Se connecter au shell MongoDB
docker compose exec mongodb mongosh
# Vérifier le statut
db.adminCommand({ serverStatus: 1 }).connections
# Vérifier la latence
db.adminCommand({ ping: 1 })
```
---
## 3. Diagnostic de perte de messages
### 3.1 Arbre de décision
```
Message manquant dans le viewer ?
│
├─ Le device est-il en trace ?
│ └─ Non → Activer la trace d'abord. Les messages ne sont PAS persistés hors trace.
│
├─ Le feature flag FEATURE_EXCHANGE_PERSISTENCE est-il à true ?
│ └─ Non → Activer la persistance : FEATURE_EXCHANGE_PERSISTENCE=true
│
├─ Le message apparaît-il dans MongoDB mais pas dans le viewer ?
│ └─ Oui → Problème SSE (voir section 1) ou problème frontend (voir section 5)
│
├─ Le message n'apparaît PAS dans MongoDB ?
│ ├─ Vérifier les logs de retry : grep "All retries exhausted"
│ ├─ Vérifier le cap (5000 messages) : les anciens messages sont supprimés
│ └─ Vérifier le TTL (24h) : les messages >24h sont auto-supprimés
│
└─ Message tronqué (XML incomplet) ?
└─ Le rawXml dépassait 2MB → grep "rawXml exceeds 2MB" dans les logs
```
### 3.2 Vérifier les messages en base
```bash
# Se connecter au shell MongoDB
docker compose exec mongodb mongosh
# Compter les messages pour un device
use adam_acs
db.cwmp_exchanges.countDocuments({ deviceId: "<DEVICE_ID>" })
# Voir les derniers messages
db.cwmp_exchanges.find({ deviceId: "<DEVICE_ID>" }).sort({ timestamp: -1 }).limit(5).pretty()
# Voir la séquence max (dernier message)
db.cwmp_exchange_counters.findOne({ _id: "<DEVICE_ID>" })
```
### 3.3 Vérifier les logs de persistance
```bash
# Chercher les erreurs de persistance
docker compose logs app | grep '\[ExchangeStore\]'
# Messages spécifiques
docker compose logs app | grep 'Failed to persist exchange'
docker compose logs app | grep 'Non-critical message persistence failed'
docker compose logs app | grep 'All retries exhausted'
docker compose logs app | grep 'Message cap enforced'
docker compose logs app | grep 'rawXml exceeds 2MB'
```
### 3.4 Message supprimé par le cap
Le système maintient un maximum de 5000 messages par device. Quand ce cap est dépassé, les messages les plus anciens sont supprimés.
```bash
# Vérifier si le cap a été atteint
docker compose logs app | grep 'Message cap enforced'
```
Pour ajuster le cap :
```bash
# Nombre max de messages par device (défaut: 5000)
EXCHANGE_STORE_MAX_MESSAGES=5000
```
### 3.5 Message supprimé par le TTL
MongoDB supprime automatiquement les documents dont `createdAt` dépasse 24 heures (via l'index TTL).
```bash
# Vérifier les documents les plus anciens
docker compose exec mongodb mongosh --eval "
use adam_acs;
db.cwmp_exchanges.find({}, { createdAt: 1, deviceId: 1 }).sort({ createdAt: 1 }).limit(3).pretty()
"
```
---
## 4. Vérification des index et du TTL
### 4.1 Lister les index existants
```bash
docker compose exec mongodb mongosh --eval "
use adam_acs;
db.cwmp_exchanges.getIndexes().forEach(idx => printjson(idx))
"
```
**Index attendus :**
| Nom | Champs | Options |
|-----|--------|---------|
| `_id_` | `{ _id: 1 }` | — |
| `idx_deviceId_timestamp` | `{ deviceId: 1, timestamp: 1 }` | — |
| `idx_deviceId_sequenceNumber` | `{ deviceId: 1, sequenceNumber: 1 }` | `unique: true` |
| `idx_createdAt_ttl` | `{ createdAt: 1 }` | `expireAfterSeconds: 86400` |
### 4.2 Vérifier l'index TTL
```bash
docker compose exec mongodb mongosh --eval "
use adam_acs;
const indexes = db.cwmp_exchanges.getIndexes();
const ttlIdx = indexes.find(i => i.name === 'idx_createdAt_ttl');
if (ttlIdx) {
print('TTL index OK — expireAfterSeconds:', ttlIdx.expireAfterSeconds);
} else {
print('ERREUR: TTL index manquant !');
}
"
```
### 4.3 Recréer les index manuellement (si manquants)
Si des index sont manquants après un incident :
```bash
docker compose exec mongodb mongosh --eval "
use adam_acs;
// Index composite (deviceId + timestamp)
db.cwmp_exchanges.createIndex(
{ deviceId: 1, timestamp: 1 },
{ name: 'idx_deviceId_timestamp' }
);
// Index unique (deviceId + sequenceNumber)
db.cwmp_exchanges.createIndex(
{ deviceId: 1, sequenceNumber: 1 },
{ unique: true, name: 'idx_deviceId_sequenceNumber' }
);
// Index TTL (24h)
db.cwmp_exchanges.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 86400, name: 'idx_createdAt_ttl' }
);
print('Tous les index recréés.');
"
```
### 4.4 Vérifier le statut du TTL monitor
Le TTL monitor de MongoDB tourne par défaut toutes les 60 secondes. Pour vérifier qu'il fonctionne :
```bash
docker compose exec mongodb mongosh --eval "
const status = db.adminCommand({ serverStatus: 1 });
print('TTL deletedDocuments:', status.metrics.ttl.deletedDocuments);
print('TTL passes:', status.metrics.ttl.passes);
"
```
Si `passes` n'augmente pas, le TTL monitor pourrait être bloqué (rare, nécessite un restart MongoDB).
### 4.5 Modifier le TTL
Pour changer la durée du TTL sans recréer l'index :
```bash
docker compose exec mongodb mongosh --eval "
use adam_acs;
db.runCommand({
collMod: 'cwmp_exchanges',
index: {
name: 'idx_createdAt_ttl',
expireAfterSeconds: 172800 // 48h
}
});
"
```
N'oubliez pas de mettre à jour la variable d'environnement pour cohérence :
```bash
EXCHANGE_STORE_TTL_SECONDS=172800
```
### 4.6 Vérifier la collection counters
La collection `cwmp_exchange_counters` stocke les séquences atomiques par device :
```bash
docker compose exec mongodb mongosh --eval "
use adam_acs;
db.cwmp_exchange_counters.find().forEach(doc => {
print(doc._id, '→ seq:', doc.seq);
})
"
```
Si un compteur est incohérent avec les données réelles :
```bash
# Recalculer le max sequenceNumber pour un device
docker compose exec mongodb mongosh --eval "
use adam_acs;
const deviceId = '<DEVICE_ID>';
const maxSeq = db.cwmp_exchanges.find({ deviceId }).sort({ sequenceNumber: -1 }).limit(1).toArray();
if (maxSeq.length > 0) {
db.cwmp_exchange_counters.updateOne(
{ _id: deviceId },
{ \$set: { seq: maxSeq[0].sequenceNumber } }
);
print('Counter reset to', maxSeq[0].sequenceNumber);
}
"
```
---
## 5. Problèmes frontend (SequenceViewer)
### 5.1 Le viewer ne se connecte pas au SSE
**Causes possibles :**
1. Le device n'est pas en trace → activer la trace d'abord
2. Le feature flag `FEATURE_SSE_ENABLED` est à `false` → erreur 503
3. Problème CORS si accès depuis un domaine différent
**Diagnostic :** Ouvrir la console navigateur (F12) et chercher les erreurs EventSource.
### 5.2 Les messages ne s'affichent pas en temps réel
Si les messages arrivent dans MongoDB mais pas dans le viewer :
1. Vérifier l'indicateur de connexion : doit être "connected" (vert)
2. Si "disconnected" (rouge) : voir section 1
3. Si "paused" (jaune) : l'auto-scroll est désactivé car l'utilisateur a scrollé vers le haut — cliquer "Resume auto-scroll"
### 5.3 Performance lente avec beaucoup de messages
Le viewer utilise du virtual scrolling pour >200 messages. Si la performance se dégrade :
- Le cap client est fixé à 1000 messages en mémoire (les plus anciens sont purgés côté client)
- Si le nombre total dépasse 5000 en base, les plus anciens sont purgés côté serveur
- Utiliser "Clear exchanges" pour repartir d'un état vide
### 5.4 L'inspecteur XML affiche du contenu brut (non formaté)
Si le XML n'est pas mis en forme :
- Le XML est peut-être malformé → l'inspecteur affiche le contenu brut en fallback
- Vérifier si le message a un `rawXml` vide (cas "Empty")
---
## 6. Feature flags et rollback
### 6.1 Flags disponibles
| Variable d'environnement | Défaut | Effet si `false`/`file` |
|--------------------------|--------|-------------------------|
| `FEATURE_EXCHANGE_PERSISTENCE` | `true` | Les échanges ne sont pas écrits en MongoDB |
| `FEATURE_SSE_ENABLED` | `true` | L'endpoint SSE retourne 503, le client utilise le REST polling (5s) |
| `FEATURE_EXCHANGE_API_SOURCE` | `mongodb` | Si `file`, l'API lit depuis l'ancien système de fichiers |
### 6.2 Procédure de rollback rapide
En cas de problème critique :
```bash
# 1. Désactiver la persistance (les sessions CWMP continuent normalement)
FEATURE_EXCHANGE_PERSISTENCE=false
# 2. Désactiver le SSE (le frontend utilise le polling REST)
FEATURE_SSE_ENABLED=false
# 3. Revenir à la source fichier pour l'API
FEATURE_EXCHANGE_API_SOURCE=file
```
Redémarrer l'application après modification :
```bash
docker compose restart app
```
### 6.3 Vérifier l'état actuel des flags
Les valeurs actives sont loguées au démarrage de l'application. Vous pouvez aussi interroger le health check :
```bash
curl -s http://localhost:8080/api/v1/health/tr069-exchanges | python3 -m json.tool
```
---
## Commandes utiles — Résumé rapide
```bash
# Status général
docker compose logs app --tail 20 | grep -E '\[ExchangeStore\]|\[SSE\]'
# Compter les échanges d'un device
docker compose exec mongodb mongosh --eval "use adam_acs; db.cwmp_exchanges.countDocuments({deviceId:'<ID>'})"
# Vérifier les index
docker compose exec mongodb mongosh --eval "use adam_acs; db.cwmp_exchanges.getIndexes().forEach(i=>printjson(i))"
# Vérifier le TTL monitor
docker compose exec mongodb mongosh --eval "printjson(db.adminCommand({serverStatus:1}).metrics.ttl)"
# Tester la connexion SSE manuellement
curl -N -H "Accept: text/event-stream" http://localhost:8080/api/v1/tr069-trace/<DEVICE_ID>/exchanges/stream
# Tester la reconnexion avec Last-Event-ID
curl -N -H "Accept: text/event-stream" -H "Last-Event-ID: 42" http://localhost:8080/api/v1/tr069-trace/<DEVICE_ID>/exchanges/stream
# Purger les échanges d'un device
curl -X DELETE http://localhost:8080/api/v1/tr069-trace/<DEVICE_ID>/exchanges
```
+348
View File
@@ -0,0 +1,348 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { CwmpExchange } from '../composables/useExchangeStream'
const props = defineProps<{
selectedMessage: CwmpExchange | null
}>()
const panelWidth = ref(400)
const isResizing = ref(false)
const copyConfirmation = ref(false)
const MIN_WIDTH = 300
const MAX_WIDTH_PERCENT = 0.7
const maxWidth = computed(() => {
if (typeof window !== 'undefined') {
return Math.floor(window.innerWidth * MAX_WIDTH_PERCENT)
}
return 900
})
const panelStyle = computed(() => ({
width: '100%',
height: '100%',
}))
// XML formatting with syntax highlighting
function formatXml(xml: string): string {
if (!xml || xml.trim() === '') return ''
try {
return prettyPrintXml(xml)
} catch {
// If XML is unparseable, return raw text escaped
return escapeHtml(xml)
}
}
function prettyPrintXml(xml: string): string {
// Simple XML formatter with 2-space indentation
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()
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
// Syntax highlighting for XML
function highlightXml(formattedXml: string): string {
if (!formattedXml) return ''
const escaped = escapeHtml(formattedXml)
// Highlight XML elements, attributes, and values with distinct colors
return escaped
// Processing instructions: <?xml ... ?>
.replace(
/&lt;\?([^?]*)\?&gt;/g,
'<span class="xml-processing">&lt;?$1?&gt;</span>'
)
// Closing tags: </tagName>
.replace(
/&lt;\/([a-zA-Z0-9_:\-\.]+)&gt;/g,
'<span class="xml-bracket">&lt;/</span><span class="xml-element">$1</span><span class="xml-bracket">&gt;</span>'
)
// Self-closing tags with attributes: <tag attr="val" />
.replace(
/&lt;([a-zA-Z0-9_:\-\.]+)((?:\s+[^&]*?)?)\/&gt;/g,
(_match, tag, attrs) => {
const highlightedAttrs = highlightAttributes(attrs)
return `<span class="xml-bracket">&lt;</span><span class="xml-element">${tag}</span>${highlightedAttrs}<span class="xml-bracket">/&gt;</span>`
}
)
// Opening tags with attributes: <tag attr="val">
.replace(
/&lt;([a-zA-Z0-9_:\-\.]+)((?:\s+[^&]*?)?)&gt;/g,
(_match, tag, attrs) => {
const highlightedAttrs = highlightAttributes(attrs)
return `<span class="xml-bracket">&lt;</span><span class="xml-element">${tag}</span>${highlightedAttrs}<span class="xml-bracket">&gt;</span>`
}
)
}
function highlightAttributes(attrs: string): string {
if (!attrs || attrs.trim() === '') return ''
// Highlight attribute="value" pairs
return attrs.replace(
/([a-zA-Z0-9_:\-\.]+)=&quot;([^&]*?)&quot;/g,
'<span class="xml-attribute"> $1</span>=<span class="xml-value">&quot;$2&quot;</span>'
)
}
const formattedXml = computed(() => {
if (!props.selectedMessage) return ''
return formatXml(props.selectedMessage.rawXml)
})
const highlightedXml = computed(() => {
if (!formattedXml.value) return ''
return highlightXml(formattedXml.value)
})
const hasContent = computed(() => {
if (!props.selectedMessage) return false
return props.selectedMessage.rawXml && props.selectedMessage.rawXml.trim() !== ''
})
const isUnparseable = computed(() => {
if (!props.selectedMessage) return false
if (!hasContent.value) return false
// Check if XML starts with < — if not, it's likely unparseable
const trimmed = props.selectedMessage.rawXml.trim()
return !trimmed.startsWith('<')
})
const headerText = computed(() => {
if (!props.selectedMessage) return ''
return `${props.selectedMessage.cwmpMethod} — ${props.selectedMessage.direction}`
})
// Copy to clipboard
async function copyToClipboard() {
if (!props.selectedMessage?.rawXml) return
try {
await navigator.clipboard.writeText(props.selectedMessage.rawXml)
copyConfirmation.value = true
setTimeout(() => {
copyConfirmation.value = false
}, 2000)
} catch {
// Fallback for older browsers
const textArea = document.createElement('textarea')
textArea.value = props.selectedMessage.rawXml
textArea.style.position = 'fixed'
textArea.style.opacity = '0'
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
copyConfirmation.value = true
setTimeout(() => {
copyConfirmation.value = false
}, 2000)
}
}
// Resize handling
function startResize(event: MouseEvent) {
event.preventDefault()
isResizing.value = true
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', stopResize)
}
function onResize(event: MouseEvent) {
if (!isResizing.value) return
const newWidth = window.innerWidth - event.clientX
panelWidth.value = Math.min(Math.max(newWidth, MIN_WIDTH), maxWidth.value)
}
function stopResize() {
isResizing.value = false
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
}
// Reset copy confirmation when message changes
watch(
() => props.selectedMessage,
() => {
copyConfirmation.value = false
}
)
</script>
<template>
<div class="inspector-panel border-start" :style="panelStyle">
<!-- Resize handle -->
<div
class="inspector-resize-handle"
@mousedown="startResize"
></div>
<!-- No message selected placeholder -->
<div v-if="!selectedMessage" class="inspector-placeholder d-flex align-items-center justify-content-center h-100 text-muted">
<div class="text-center p-4">
<i class="bi bi-file-earmark-code fs-1 d-block mb-3 opacity-50"></i>
<p class="mb-0">Sélectionnez un message pour inspecter son contenu XML</p>
</div>
</div>
<!-- Message content -->
<div v-else class="d-flex flex-column h-100">
<!-- Header -->
<div class="inspector-header border-bottom px-3 py-2 d-flex align-items-center justify-content-between bg-light">
<div class="fw-bold text-truncate me-2">
{{ headerText }}
</div>
<button
class="btn btn-sm btn-outline-secondary d-flex align-items-center gap-1"
:class="{ 'btn-success': copyConfirmation }"
@click="copyToClipboard"
:disabled="!hasContent"
>
<i :class="copyConfirmation ? 'bi bi-check-lg' : 'bi bi-clipboard'"></i>
<span>{{ copyConfirmation ? 'Copié' : 'Copy' }}</span>
</button>
</div>
<!-- XML Content -->
<div class="inspector-content flex-grow-1 overflow-auto p-3">
<!-- Empty or unparseable XML -->
<div v-if="!hasContent" class="text-muted fst-italic">
Aucun contenu XML disponible pour ce message.
</div>
<!-- Unparseable XML (not starting with <) -->
<div v-else-if="isUnparseable" class="text-muted">
<p class="fst-italic mb-2">Contenu non-XML :</p>
<pre class="xml-raw mb-0"><code>{{ selectedMessage.rawXml }}</code></pre>
</div>
<!-- Formatted and highlighted XML -->
<pre v-else class="xml-highlighted mb-0"><code v-html="highlightedXml"></code></pre>
</div>
</div>
</div>
</template>
<style scoped>
.inspector-panel {
position: relative;
height: 100%;
background-color: var(--bs-body-bg, #fff);
overflow: hidden;
display: flex;
flex-direction: column;
}
.inspector-resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
z-index: 10;
transition: background-color 0.2s;
}
.inspector-resize-handle:hover,
.inspector-resize-handle:active {
background-color: var(--bs-primary, #ff7900);
}
.inspector-placeholder {
color: var(--bs-secondary, #666);
}
.inspector-header {
flex-shrink: 0;
}
.inspector-content {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: 0.8125rem;
line-height: 1.5;
}
.inspector-content pre {
white-space: pre-wrap;
word-wrap: break-word;
background: transparent;
border: none;
padding: 0;
}
/* XML Syntax Highlighting */
:deep(.xml-element) {
color: #0550ae;
}
:deep(.xml-attribute) {
color: #953800;
}
:deep(.xml-value) {
color: #0a3069;
}
:deep(.xml-bracket) {
color: #24292f;
}
:deep(.xml-processing) {
color: #6e7781;
font-style: italic;
}
.xml-raw {
background-color: var(--bs-light, #f8f9fa);
border-radius: 0.25rem;
padding: 0.75rem;
font-size: 0.8125rem;
}
</style>
+679
View File
@@ -0,0 +1,679 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { formatTimestamp, truncateMethod, needsTruncation, formatTraceStartTime } from '../helpers/sequence_viewer_helpers'
export interface CwmpExchange {
sequenceNumber: number
timestamp: string
direction: 'CPE→ACS' | 'ACS→CPE'
cwmpMethod: string
sessionId: string
rawXml: string
}
export interface SequenceViewerProps {
deviceId: string
device: {
identifier: string
manufacturer: string
productClass: string
serialNumber: string
}
traceStartedAt: string
compact?: boolean
}
const props = withDefaults(defineProps<SequenceViewerProps>(), {
compact: false,
})
const emit = defineEmits<{
(e: 'select', message: CwmpExchange | null): void
}>()
// --- Constants ---
const BATCH_SIZE = 50
// --- State ---
const messages = ref<CwmpExchange[]>([])
const connectionState = ref<'connected' | 'paused' | 'disconnected'>('disconnected')
const isAutoScrollEnabled = ref(true)
const selectedMessage = ref<CwmpExchange | null>(null)
const showClearConfirm = ref(false)
const clearError = ref('')
const isClearing = ref(false)
// Lazy loading state
const isLoadingOlder = ref(false)
const hasMoreOlderMessages = ref(true)
const totalMessages = ref(0)
// Scroll container ref
const scrollContainer = ref<HTMLElement | null>(null)
// Sentinel element at top for IntersectionObserver
const topSentinel = ref<HTMLElement | null>(null)
// IntersectionObserver instance
let intersectionObserver: IntersectionObserver | null = null
// --- SSE Connection ---
let eventSource: EventSource | null = null
let lastEventId: string | null = null
function connect() {
if (eventSource) {
eventSource.close()
}
const url = `/api/v1/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges/stream`
eventSource = new EventSource(url)
eventSource.onopen = () => {
connectionState.value = isAutoScrollEnabled.value ? 'connected' : 'paused'
}
eventSource.onmessage = (event) => {
try {
const exchange: CwmpExchange = JSON.parse(event.data)
lastEventId = event.lastEventId || String(exchange.sequenceNumber)
messages.value.push(exchange)
if (isAutoScrollEnabled.value) {
nextTick(() => scrollToBottom())
}
} catch {
// Ignore parse errors
}
}
eventSource.onerror = () => {
connectionState.value = 'disconnected'
// EventSource handles reconnection automatically (3s retry)
}
}
function disconnect() {
if (eventSource) {
eventSource.close()
eventSource = null
}
connectionState.value = 'disconnected'
}
// --- Initial data fetch (latest BATCH_SIZE messages) ---
async function fetchInitialExchanges() {
try {
const response = await fetch(
`/api/v1/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges?limit=${BATCH_SIZE}&latest=true`
)
if (response.ok) {
const data = await response.json()
messages.value = data.exchanges || []
totalMessages.value = data.metadata?.total ?? 0
// Determine if there are older messages to load
hasMoreOlderMessages.value = messages.value.length < totalMessages.value
if (messages.value.length > 0) {
lastEventId = String(messages.value[messages.value.length - 1].sequenceNumber)
}
nextTick(() => scrollToBottom())
}
} catch {
// Silently handle — SSE will provide messages
}
}
// --- Lazy loading: load older messages in batches of 50 ---
async function loadOlderMessages() {
if (isLoadingOlder.value || !hasMoreOlderMessages.value || messages.value.length === 0) return
isLoadingOlder.value = true
try {
// Get the timestamp of the oldest currently loaded message
const oldestTimestamp = messages.value[0].timestamp
const response = await fetch(
`/api/v1/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges?limit=${BATCH_SIZE}&before=${encodeURIComponent(oldestTimestamp)}`
)
if (response.ok) {
const data = await response.json()
const olderBatch: CwmpExchange[] = data.exchanges || []
if (olderBatch.length === 0) {
hasMoreOlderMessages.value = false
} else {
// Preserve scroll position when prepending older messages
const container = scrollContainer.value
const previousScrollHeight = container?.scrollHeight ?? 0
// Prepend older messages (they're already in ascending order)
messages.value = [...olderBatch, ...messages.value]
// If we received fewer than BATCH_SIZE, there are no more older messages
if (olderBatch.length < BATCH_SIZE) {
hasMoreOlderMessages.value = false
}
// Restore scroll position after DOM update
nextTick(() => {
if (container) {
const newScrollHeight = container.scrollHeight
container.scrollTop = newScrollHeight - previousScrollHeight
}
})
}
}
} catch {
// Silently handle fetch errors
} finally {
isLoadingOlder.value = false
}
}
// --- IntersectionObserver for lazy loading older messages ---
function setupIntersectionObserver() {
if (intersectionObserver) {
intersectionObserver.disconnect()
}
intersectionObserver = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (entry && entry.isIntersecting && hasMoreOlderMessages.value && !isLoadingOlder.value) {
loadOlderMessages()
}
},
{
root: scrollContainer.value,
rootMargin: '100px 0px 0px 0px', // Trigger 100px before reaching the top
threshold: 0,
}
)
if (topSentinel.value) {
intersectionObserver.observe(topSentinel.value)
}
}
function teardownIntersectionObserver() {
if (intersectionObserver) {
intersectionObserver.disconnect()
intersectionObserver = null
}
}
// --- Auto-scroll management ---
function handleScroll() {
if (!scrollContainer.value) return
const { scrollTop, scrollHeight, clientHeight } = scrollContainer.value
const isAtBottom = scrollHeight - scrollTop - clientHeight < 60
if (isAtBottom) {
if (!isAutoScrollEnabled.value) {
isAutoScrollEnabled.value = true
if (connectionState.value === 'paused') {
connectionState.value = 'connected'
}
}
} else {
if (isAutoScrollEnabled.value) {
isAutoScrollEnabled.value = false
if (connectionState.value === 'connected') {
connectionState.value = 'paused'
}
}
}
}
function resumeAutoScroll() {
isAutoScrollEnabled.value = true
if (connectionState.value === 'paused') {
connectionState.value = 'connected'
}
scrollToBottom()
}
function scrollToBottom() {
if (!scrollContainer.value) return
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
}
// --- Message selection ---
function selectMessage(message: CwmpExchange) {
if (selectedMessage.value?.sequenceNumber === message.sequenceNumber) {
selectedMessage.value = null
emit('select', null)
} else {
selectedMessage.value = message
emit('select', message)
}
}
// --- Clear exchanges ---
function requestClear() {
showClearConfirm.value = true
clearError.value = ''
}
function cancelClear() {
showClearConfirm.value = false
clearError.value = ''
}
async function confirmClear() {
isClearing.value = true
clearError.value = ''
try {
const response = await fetch(
`/api/v1/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges`,
{ method: 'DELETE' }
)
if (response.status === 204 || response.ok) {
messages.value = []
selectedMessage.value = null
emit('select', null)
lastEventId = null
showClearConfirm.value = false
hasMoreOlderMessages.value = false
totalMessages.value = 0
// Verify SSE connection is still active
if (connectionState.value === 'disconnected') {
connect()
}
} else {
clearError.value = 'Échec de la suppression des échanges. Veuillez réessayer.'
}
} catch {
clearError.value = 'Erreur réseau lors de la suppression.'
} finally {
isClearing.value = false
}
}
// --- Computed ---
const hasMessages = computed(() => messages.value.length > 0)
const connectionBadgeClass = computed(() => {
switch (connectionState.value) {
case 'connected':
return 'bg-success'
case 'paused':
return 'bg-warning text-dark'
case 'disconnected':
return 'bg-danger'
default:
return 'bg-secondary'
}
})
const connectionLabel = computed(() => {
switch (connectionState.value) {
case 'connected':
return 'Connecté'
case 'paused':
return 'Pause'
case 'disconnected':
return 'Déconnecté'
default:
return ''
}
})
// --- Lifecycle ---
onMounted(async () => {
await fetchInitialExchanges()
connect()
// Setup IntersectionObserver after initial render
nextTick(() => {
setupIntersectionObserver()
})
})
onBeforeUnmount(() => {
disconnect()
teardownIntersectionObserver()
})
// Watch for deviceId changes
watch(
() => props.deviceId,
async () => {
disconnect()
teardownIntersectionObserver()
messages.value = []
selectedMessage.value = null
emit('select', null)
lastEventId = null
hasMoreOlderMessages.value = true
totalMessages.value = 0
await fetchInitialExchanges()
connect()
nextTick(() => {
setupIntersectionObserver()
})
}
)
</script>
<template>
<!-- Session Header (hidden in compact mode) -->
<div v-if="!compact" class="card border-0 shadow-sm mb-3">
<div class="card-body py-3 px-4">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-3">
<div>
<span class="text-muted fw-bold text-uppercase" style="font-size: 0.62rem; letter-spacing: 0.08em">
Échanges TR-069
</span>
<div class="d-flex align-items-center gap-2 mt-1">
<span class="fw-semibold small">{{ device.manufacturer }}</span>
<span class="text-muted small">{{ device.productClass }}</span>
<span class="font-monospace small text-muted">{{ device.serialNumber }}</span>
</div>
</div>
<div class="vr mx-2" style="height: 30px"></div>
<div>
<span class="text-muted fw-bold text-uppercase" style="font-size: 0.58rem; letter-spacing: 0.06em">
Trace démarrée
</span>
<div class="font-monospace small">{{ formatTraceStartTime(traceStartedAt) }}</div>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge" :class="connectionBadgeClass">
{{ connectionLabel }}
</span>
<button
v-if="hasMessages"
class="btn btn-outline-danger btn-sm"
@click="requestClear"
:disabled="isClearing"
>
Effacer les échanges
</button>
</div>
</div>
</div>
</div>
<!-- Compact toolbar (shown in compact mode) -->
<div v-if="compact" class="d-flex align-items-center justify-content-between px-2 py-1 border-bottom bg-light">
<span class="badge" :class="connectionBadgeClass" style="font-size: 0.65rem">
{{ connectionLabel }}
</span>
<button
v-if="hasMessages"
class="btn btn-outline-danger py-0 px-2"
style="font-size: 0.65rem"
@click="requestClear"
:disabled="isClearing"
>
Effacer
</button>
</div>
<!-- Empty state (no lifelines when empty) -->
<div v-if="!hasMessages" class="card border-0 shadow-sm">
<div class="card-body py-5 text-center">
<div class="text-muted mb-2">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" viewBox="0 0 16 16" class="opacity-25">
<path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
<path d="M6 11.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5m-2-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5m-2-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5"/>
</svg>
</div>
<p class="text-muted mb-0">
Aucun échange enregistré. Les messages SOAP apparaîtront ici lors de la prochaine connexion du CPE.
</p>
</div>
</div>
<!-- Sequence Diagram -->
<div v-else class="card border-0 shadow-sm">
<div class="card-body p-0">
<!-- Resume auto-scroll button -->
<div v-if="!isAutoScrollEnabled" class="sticky-top bg-white border-bottom px-3 py-2 text-center" style="z-index: 10">
<button class="btn btn-sm btn-outline-primary" @click="resumeAutoScroll">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1"/>
</svg>
Reprendre le défilement automatique
</button>
</div>
<!-- Lifeline headers -->
<div class="d-flex align-items-center border-bottom px-3 py-2 bg-light">
<div class="text-center fw-bold small" style="width: 24px">CPE</div>
<div class="flex-grow-1"></div>
<div class="text-center fw-bold small" style="width: 24px">ACS</div>
</div>
<!-- Messages container with scroll -->
<div
ref="scrollContainer"
class="sequence-messages"
style="max-height: 500px; overflow-y: auto"
@scroll="handleScroll"
>
<!-- Top sentinel for IntersectionObserver (triggers older message loading) -->
<div ref="topSentinel" class="top-sentinel">
<div v-if="isLoadingOlder" class="text-center py-2">
<div class="spinner-border spinner-border-sm text-muted" role="status">
<span class="visually-hidden">Chargement des messages plus anciens…</span>
</div>
<span class="text-muted small ms-2">Chargement…</span>
</div>
<div v-else-if="!hasMoreOlderMessages && messages.length > BATCH_SIZE" class="text-center py-2">
<span class="text-muted small">Tous les messages sont chargés</span>
</div>
</div>
<div
v-for="message in messages"
:key="message.sequenceNumber"
class="sequence-row d-flex align-items-center px-3 py-2 border-bottom"
:class="{
'sequence-row--selected': selectedMessage?.sequenceNumber === message.sequenceNumber,
'sequence-row--cpe-to-acs': message.direction === 'CPE→ACS',
'sequence-row--acs-to-cpe': message.direction === 'ACS→CPE',
}"
role="button"
tabindex="0"
@click="selectMessage(message)"
@keydown.enter="selectMessage(message)"
@keydown.space.prevent="selectMessage(message)"
>
<!-- CPE lifeline -->
<div class="lifeline-column text-center" style="width: 24px">
<div class="lifeline-bar"></div>
</div>
<!-- Arrow area -->
<div class="flex-grow-1 d-flex flex-column align-items-center position-relative">
<!-- Method label -->
<span
v-if="message.cwmpMethod === 'Empty'"
class="small text-muted fst-italic mb-1"
>
POST vide
</span>
<span
v-else
class="small fw-medium mb-1"
:title="needsTruncation(message.cwmpMethod) ? message.cwmpMethod : undefined"
>
{{ truncateMethod(message.cwmpMethod) }}
</span>
<!-- Arrow -->
<div class="sequence-arrow w-100 position-relative" :class="[
message.direction === 'CPE→ACS' ? 'arrow-right' : 'arrow-left',
message.cwmpMethod === 'Empty' ? 'arrow-empty' : ''
]">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<!-- Timestamp -->
<span class="font-monospace text-muted mt-1" style="font-size: 0.7rem">
{{ formatTimestamp(message.timestamp) }}
</span>
</div>
<!-- ACS lifeline -->
<div class="lifeline-column text-center" style="width: 24px">
<div class="lifeline-bar"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Clear confirmation dialog -->
<Teleport to="body">
<div v-if="showClearConfirm" class="modal-backdrop fade show"></div>
<div
v-if="showClearConfirm"
class="modal fade show d-block"
tabindex="-1"
role="dialog"
aria-labelledby="clearConfirmLabel"
aria-modal="true"
>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 id="clearConfirmLabel" class="modal-title">Effacer les échanges</h5>
<button type="button" class="btn-close" aria-label="Fermer" @click="cancelClear"></button>
</div>
<div class="modal-body">
<p>
Êtes-vous sûr de vouloir supprimer tous les échanges enregistrés pour cet équipement ?
Cette action est irréversible.
</p>
<div v-if="clearError" class="alert alert-danger small mb-0">{{ clearError }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" @click="cancelClear">
Annuler
</button>
<button
type="button"
class="btn btn-danger btn-sm"
:disabled="isClearing"
@click="confirmClear"
>
{{ isClearing ? 'Suppression…' : 'Confirmer la suppression' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.sequence-row {
cursor: pointer;
transition: background-color 0.15s ease;
}
.sequence-row:hover {
background-color: var(--bs-light, #f8f9fa);
}
.sequence-row--selected {
background-color: var(--bs-primary-bg-subtle, #e7f1ff) !important;
border-left: 3px solid var(--bs-primary, #ff7900) !important;
}
.sequence-row--cpe-to-acs .arrow-line {
background-color: var(--bs-primary, #ff7900);
}
.sequence-row--cpe-to-acs .arrow-head {
border-left-color: var(--bs-primary, #ff7900);
}
.sequence-row--acs-to-cpe .arrow-line {
background-color: var(--bs-info, #527edb);
}
.sequence-row--acs-to-cpe .arrow-head {
border-right-color: var(--bs-info, #527edb);
}
.lifeline-column {
position: relative;
}
.lifeline-bar {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 2px;
background-color: var(--bs-border-color, #dee2e6);
}
.sequence-arrow {
display: flex;
align-items: center;
height: 2px;
position: relative;
}
.sequence-arrow .arrow-line {
flex-grow: 1;
height: 2px;
}
.sequence-arrow .arrow-head {
width: 0;
height: 0;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
}
.sequence-arrow.arrow-right {
flex-direction: row;
}
.sequence-arrow.arrow-right .arrow-head {
border-left: 8px solid;
border-left-color: inherit;
}
.sequence-arrow.arrow-left {
flex-direction: row-reverse;
}
.sequence-arrow.arrow-left .arrow-head {
border-right: 8px solid;
border-right-color: inherit;
}
/* Empty POST: dashed gray arrow */
.sequence-arrow.arrow-empty .arrow-line {
background: none;
border-top: 2px dashed #adb5bd;
height: 0;
}
.sequence-arrow.arrow-empty .arrow-head {
border-left-color: #adb5bd;
border-right-color: #adb5bd;
}
.sequence-messages {
scrollbar-width: thin;
}
.top-sentinel {
min-height: 1px;
}
</style>
+290
View File
@@ -0,0 +1,290 @@
import { ref, onUnmounted, type Ref } from 'vue'
/**
* Represents a single CWMP exchange message received from the backend.
*/
export interface CwmpExchange {
deviceId: string
direction: 'CPE→ACS' | 'ACS→CPE'
timestamp: string
sessionId: string
cwmpMethod: string
rawXml: string
sequenceNumber: number
createdAt?: string
}
export type ConnectionState = 'connected' | 'paused' | 'disconnected'
export interface UseExchangeStreamReturn {
/** Reactive array of received exchange messages */
messages: Ref<CwmpExchange[]>
/** Current connection state */
connectionState: Ref<ConnectionState>
/** Whether auto-scroll is enabled (paused when user scrolls up) */
isAutoScrollEnabled: Ref<boolean>
/** Connect to the SSE stream for the device */
connect: () => void
/** Disconnect from the SSE stream */
disconnect: () => void
/** Resume auto-scroll and scroll to the latest message */
resumeAutoScroll: () => void
/** Clear all exchanges server-side and reset local state */
clearMessages: () => Promise<void>
}
const POLLING_INTERVAL_MS = 5000
const MAX_CACHED_MESSAGES = 1000
/**
* Composable for managing real-time CWMP exchange streaming via SSE,
* with fallback to REST polling if SSE is unavailable.
*
* Requirements: 6.2, 6.3, 6.4, 6.5, 6.6, 6.7, 6.8, 8.4
*/
export function useExchangeStream(deviceId: string): UseExchangeStreamReturn {
const messages = ref<CwmpExchange[]>([])
const connectionState = ref<ConnectionState>('disconnected')
const isAutoScrollEnabled = ref<boolean>(true)
let eventSource: EventSource | null = null
let lastSequenceNumber: number | null = null
let pollingTimer: ReturnType<typeof setInterval> | null = null
let sseSupported = true
let reconnectAttempts = 0
const MAX_RECONNECT_ATTEMPTS = 3
/**
* Build the SSE stream URL for the device.
*/
function getStreamUrl(): string {
return `/api/v1/tr069-trace/${encodeURIComponent(deviceId)}/exchanges/stream`
}
/**
* Build the exchanges REST API URL for the device.
*/
function getExchangesUrl(after?: string): string {
const base = `/api/v1/tr069-trace/${encodeURIComponent(deviceId)}/exchanges`
if (after) {
return `${base}?after=${encodeURIComponent(after)}`
}
return base
}
/**
* Append a message to the messages array, enforcing the client-side cap.
*/
function appendMessage(exchange: CwmpExchange) {
messages.value = [...messages.value, exchange]
if (messages.value.length > MAX_CACHED_MESSAGES) {
messages.value = messages.value.slice(-MAX_CACHED_MESSAGES)
}
lastSequenceNumber = exchange.sequenceNumber
}
/**
* Connect to the SSE stream.
* If SSE fails (404, 503, or repeated connection errors), falls back to REST polling.
*
* Requirements: 6.2, 6.7, 6.8
*/
function connect() {
// Clean up any existing connection
disconnect()
reconnectAttempts = 0
if (sseSupported) {
connectSSE()
} else {
startPolling()
}
}
/**
* Establish an EventSource connection to the SSE endpoint.
*/
function connectSSE() {
const url = getStreamUrl()
eventSource = new EventSource(url)
eventSource.onopen = () => {
connectionState.value = isAutoScrollEnabled.value ? 'connected' : 'paused'
reconnectAttempts = 0
}
eventSource.onmessage = (event: MessageEvent) => {
try {
const exchange: CwmpExchange = JSON.parse(event.data)
appendMessage(exchange)
// Update connection state based on auto-scroll
connectionState.value = isAutoScrollEnabled.value ? 'connected' : 'paused'
} catch {
// Ignore parse errors for malformed SSE data
}
}
eventSource.onerror = () => {
reconnectAttempts++
// EventSource automatically reconnects using the retry interval (3s) set by the server.
// It also sends the Last-Event-ID header automatically on reconnection (Requirement 6.8).
// After MAX_RECONNECT_ATTEMPTS consecutive errors, fall back to REST polling.
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
// SSE seems unavailable — fall back to REST polling
sseSupported = false
closeEventSource()
connectionState.value = 'disconnected'
startPolling()
} else {
connectionState.value = 'disconnected'
}
}
}
/**
* Close the EventSource connection without triggering a fallback.
*/
function closeEventSource() {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
/**
* Disconnect from the SSE stream and stop any polling.
* Preserves all previously displayed messages (Requirement 6.7).
*/
function disconnect() {
closeEventSource()
stopPolling()
connectionState.value = 'disconnected'
}
/**
* Start REST polling as a fallback when SSE is unavailable.
* Polls every 5 seconds for new messages using the `after` timestamp parameter.
*/
function startPolling() {
stopPolling()
connectionState.value = isAutoScrollEnabled.value ? 'connected' : 'paused'
pollingTimer = setInterval(async () => {
await pollExchanges()
}, POLLING_INTERVAL_MS)
// Do an immediate poll
void pollExchanges()
}
/**
* Stop REST polling.
*/
function stopPolling() {
if (pollingTimer !== null) {
clearInterval(pollingTimer)
pollingTimer = null
}
}
/**
* Fetch new exchanges from the REST API using the `after` parameter
* based on the last received message timestamp.
*/
async function pollExchanges() {
try {
const lastMessage = messages.value.length > 0 ? messages.value[messages.value.length - 1] : null
const after = lastMessage?.timestamp
const url = getExchangesUrl(after)
const response = await fetch(url)
if (!response.ok) {
if (response.status === 404 || response.status === 503) {
// Device not traced or service unavailable
connectionState.value = 'disconnected'
stopPolling()
return
}
return
}
const data = await response.json()
const exchanges: CwmpExchange[] = data.exchanges || []
for (const exchange of exchanges) {
// Avoid duplicates by checking sequence number
if (lastSequenceNumber === null || exchange.sequenceNumber > lastSequenceNumber) {
appendMessage(exchange)
}
}
connectionState.value = isAutoScrollEnabled.value ? 'connected' : 'paused'
} catch {
// Network error during polling — keep trying
connectionState.value = 'disconnected'
}
}
/**
* Resume auto-scroll and update connection state.
* Requirement 6.6: button allows user to resume auto-scroll.
*/
function resumeAutoScroll() {
isAutoScrollEnabled.value = true
if (eventSource || pollingTimer) {
connectionState.value = 'connected'
}
}
/**
* Clear all exchanges on the server and reset local state.
* Calls DELETE API and resets messages array.
* After clearing, verifies SSE connection is still active and reconnects if needed.
*
* Requirement 8.4: verify SSE connection remains active,
* reset to empty state, continue receiving new messages.
*/
async function clearMessages(): Promise<void> {
const url = `/api/v1/tr069-trace/${encodeURIComponent(deviceId)}/exchanges`
const response = await fetch(url, { method: 'DELETE' })
if (!response.ok && response.status !== 204) {
throw new Error(`Failed to clear exchanges: ${response.status} ${response.statusText}`)
}
// Reset local state
messages.value = []
lastSequenceNumber = null
// Verify SSE connection is still active; reconnect if needed (Requirement 8.4)
if (eventSource) {
if (eventSource.readyState === EventSource.CLOSED) {
connectSSE()
}
// If still open, continue receiving — nothing else needed
} else if (pollingTimer) {
// Polling is still running, continue as normal
} else {
// No active connection — attempt reconnection
connect()
}
}
// Cleanup on component unmount (Requirement 6.10)
onUnmounted(() => {
disconnect()
})
return {
messages,
connectionState,
isAutoScrollEnabled,
connect,
disconnect,
resumeAutoScroll,
clearMessages,
}
}
@@ -0,0 +1,51 @@
/**
* Formatting helpers for the SequenceViewer component.
* Extracted for testability and reuse.
*/
/**
* Determines the CSS arrow direction class based on message direction.
* "CPE→ACS" messages point left-to-right (arrow-right).
* "ACS→CPE" messages point right-to-left (arrow-left).
*/
export function getArrowClass(direction: 'CPE→ACS' | 'ACS→CPE'): 'arrow-right' | 'arrow-left' {
return direction === 'CPE→ACS' ? 'arrow-right' : 'arrow-left'
}
/**
* Formats an ISO 8601 timestamp into HH:mm:ss.SSS display format.
* Uses UTC time to ensure consistent formatting regardless of timezone.
*/
export function formatTimestamp(isoTimestamp: string): string {
const date = new Date(isoTimestamp)
const hours = String(date.getUTCHours()).padStart(2, '0')
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
const seconds = String(date.getUTCSeconds()).padStart(2, '0')
const millis = String(date.getUTCMilliseconds()).padStart(3, '0')
return `${hours}:${minutes}:${seconds}.${millis}`
}
/**
* Truncates a CWMP method name to 40 characters if it exceeds that length.
* Appends an ellipsis character (…) when truncated.
*/
export function truncateMethod(method: string): string {
if (method.length > 40) {
return method.substring(0, 40) + '…'
}
return method
}
/**
* Returns true if the method name exceeds 40 characters and needs truncation.
*/
export function needsTruncation(method: string): boolean {
return method.length > 40
}
/**
* Formats a trace start time ISO string into a localized display string.
*/
export function formatTraceStartTime(iso: string): string {
return new Date(iso).toLocaleString()
}
+79 -8
View File
@@ -3,6 +3,9 @@ import { ref } from 'vue'
import { router, Link } from '@inertiajs/vue3'
import { useI18n } from 'vue-i18n'
import TitleBar from '../components/TitleBar.vue'
import SequenceViewer from '../components/SequenceViewer.vue'
import InspectorPanel from '../components/InspectorPanel.vue'
import type { CwmpExchange } from '../composables/useExchangeStream'
const { t } = useI18n()
@@ -16,6 +19,8 @@ const isLoading = ref(false)
const showDeleteConfirm = ref(false)
const isTraced = ref(false)
const tracingAction = ref(false)
const traceStartedAt = ref<string | null>(null)
const selectedMessage = ref<CwmpExchange | null>(null)
// Check if device is already traced
async function checkTraceStatus() {
@@ -24,7 +29,11 @@ async function checkTraceStatus() {
if (response.ok) {
const data = await response.json()
const entries = data.entries || []
isTraced.value = entries.some((e: any) => e.deviceId === device.identifier)
const entry = entries.find((e: any) => e.deviceId === device.identifier)
isTraced.value = !!entry
if (entry) {
traceStartedAt.value = entry.startedAt || new Date().toISOString()
}
}
} catch {
// Silently ignore
@@ -40,6 +49,8 @@ async function toggleTrace() {
})
if (response.ok || response.status === 204) {
isTraced.value = false
traceStartedAt.value = null
selectedMessage.value = null
}
} else {
const response = await fetch('/api/v1/tr069-trace', {
@@ -48,7 +59,9 @@ async function toggleTrace() {
body: JSON.stringify({ deviceId: device.identifier }),
})
if (response.ok) {
const data = await response.json()
isTraced.value = true
traceStartedAt.value = data.entry?.startedAt || new Date().toISOString()
}
}
} catch {
@@ -60,6 +73,10 @@ async function toggleTrace() {
checkTraceStatus()
function onMessageSelect(message: CwmpExchange | null) {
selectedMessage.value = message
}
function deleteDevice() {
router.delete(`/devices/${encodeURIComponent(device.identifier)}`, {
onSuccess: () => router.visit('/devices'),
@@ -215,6 +232,11 @@ async function resolveAllProviders() {
Actions
</a>
</li>
<li v-if="isTraced" class="nav-item">
<a class="nav-link" :class="{ active: activeSection === 'exchanges' }" href="javascript:void(0);" @click="scrollTo('exchanges')">
Échanges TR-069
</a>
</li>
<li class="nav-item">
<a class="nav-link" :class="{ active: activeSection === 'commands' }" href="javascript:void(0);" @click="scrollTo('commands')">
{{ t('titles.commands') }}
@@ -484,20 +506,43 @@ async function resolveAllProviders() {
<button class="btn btn-outline-dark btn-sm" @click="openGetModelPopup" :disabled="isLoading">
{{ t('button.getModel') }}
</button>
<span
v-if="isTraced"
class="d-inline-block"
tabindex="0"
data-bs-toggle="tooltip"
title="Tracing already active. Click 'Disable Tracing' to stop."
>
<button class="btn btn-outline-primary btn-sm" disabled>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
</svg>
Add to TR-069 monitoring
</button>
</span>
<button
class="btn btn-sm"
:class="isTraced ? 'btn-warning' : 'btn-outline-primary'"
v-if="isTraced"
class="btn btn-warning btn-sm"
:disabled="tracingAction"
@click="toggleTrace"
>
<svg v-if="!isTraced" xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293z"/>
</svg>
{{ tracingAction ? '…' : 'Disable Tracing' }}
</button>
<button
v-if="!isTraced"
class="btn btn-outline-primary btn-sm"
:disabled="tracingAction"
@click="toggleTrace"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293z"/>
</svg>
{{ tracingAction ? '…' : (isTraced ? 'Remove TR-069 monitoring' : 'Add to TR-069 monitoring') }}
{{ tracingAction ? '…' : 'Add to TR-069 monitoring' }}
</button>
<button class="btn btn-outline-danger btn-sm ms-auto" @click="showDeleteConfirm = true">
Supprimer l'équipement
@@ -513,6 +558,32 @@ async function resolveAllProviders() {
</div>
</div>
<!-- Section: Échanges TR-069 (collapsible, only when traced) -->
<div v-if="isTraced" class="accordion-section mb-3" id="exchanges">
<div class="section-header" @click="toggleSection('exchanges')">
<h2 class="h6 mb-0">Échanges TR-069</h2>
<span class="chevron" :class="{ open: isSectionOpen('exchanges') }">▾</span>
</div>
<div v-if="isSectionOpen('exchanges')" class="section-body">
<div class="d-flex gap-3" style="min-height: 400px">
<div class="flex-grow-1" style="overflow: hidden">
<SequenceViewer
:device-id="device.identifier"
:device="{
identifier: device.identifier,
manufacturer: device.deviceId?.manufacturer || '',
productClass: device.deviceId?.productClass || '',
serialNumber: device.deviceId?.serialNumber || '',
}"
:trace-started-at="traceStartedAt || ''"
@select="onMessageSelect"
/>
</div>
<InspectorPanel :selected-message="selectedMessage" />
</div>
</div>
</div>
<!-- Section: Commands for this device -->
<div class="accordion-section mb-3" id="commands">
<div class="section-header" @click="toggleSection('commands')">
+33 -31
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import TitleBar from '~/components/TitleBar.vue'
import SequenceViewer from '~/components/SequenceViewer.vue'
import InspectorPanel from '~/components/InspectorPanel.vue'
import type { CwmpExchange } from '~/composables/useExchangeStream'
type TraceEntry = {
deviceId: string
@@ -25,6 +28,13 @@ const loadingLog = ref(false)
const logError = ref('')
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
// Inspector panel state (new exchange viewer)
const inspectorMessage = ref<CwmpExchange | null>(null)
function onMessageSelect(message: CwmpExchange | null) {
inspectorMessage.value = message
}
// JSON editor modal state
const showJsonModal = ref(false)
const jsonContent = ref('')
@@ -46,10 +56,7 @@ watch(selectedDeviceId, async (id) => {
logContent.value = ''
logLines.value = 0
logError.value = ''
if (id) {
await loadLog(id)
startAutoRefresh(id)
}
inspectorMessage.value = null
})
function startAutoRefresh(deviceId: string) {
@@ -265,35 +272,30 @@ function formatJson() {
</div>
</div>
<!-- Log viewer panel (full width) -->
<div v-if="selectedDeviceId" class="card border-0 shadow-sm mt-4">
<div
class="card-header d-flex justify-content-between align-items-center bg-dark text-white py-2"
>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-success">Live</span>
<span class="small font-monospace">
{{ selectedEntry?.manufacturer }} {{ selectedEntry?.productClass }} /
{{ selectedEntry?.serialNumber }}
</span>
</div>
<div class="d-flex align-items-center gap-2">
<span class="small text-white-50">{{ logLines.toLocaleString() }} lines total</span>
<button class="btn btn-sm btn-outline-light py-0" @click="clearLog">Clear view</button>
<button class="btn btn-sm btn-outline-light py-0" @click="selectedDeviceId = null">&#x2715;</button>
</div>
<!-- Exchange Viewer panel (full width) — new sequence diagram viewer -->
<div v-if="selectedDeviceId && selectedEntry" class="mt-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="h5 mb-0">Échanges TR-069</h3>
<button class="btn btn-sm btn-outline-secondary" @click="selectedDeviceId = null">&#x2715; Fermer</button>
</div>
<div class="card-body p-0">
<div v-if="loadingLog && !logContent" class="text-muted small p-3">Loading log&hellip;</div>
<div v-else-if="logError" class="alert alert-danger m-3">{{ logError }}</div>
<div v-else-if="!logContent" class="text-muted small p-3">
No log entries yet. Waiting for SOAP exchanges&hellip;
<div class="row g-0" style="min-height: 550px">
<div class="col-6 border-end overflow-hidden">
<SequenceViewer
:device-id="selectedEntry.deviceId"
:device="{
identifier: selectedEntry.deviceId,
manufacturer: selectedEntry.manufacturer || '',
productClass: selectedEntry.productClass || '',
serialNumber: selectedEntry.serialNumber || '',
}"
:trace-started-at="selectedEntry.startedAt || ''"
:compact="true"
@select="onMessageSelect"
/>
</div>
<div class="col-6">
<InspectorPanel :selected-message="inspectorMessage" />
</div>
<pre
v-else
class="mb-0 p-3 small font-monospace"
style="background:#1e1e1e;color:#d4d4d4;max-height:600px;overflow-y:auto;white-space:pre-wrap;word-break:break-all"
>{{ logContent }}</pre>
</div>
</div>
</div>
+9
View File
@@ -39,6 +39,15 @@ export default class MongodbProvider {
dbInstance = db
this.app.container.bindValue('mongodb', db)
// Verify cwmp_exchanges indexes after boot
const { verifyIndexesAtStartup } = await import('#services/exchange_index_checker')
await verifyIndexesAtStartup(db, {
warn: (msg: string) => console.warn(msg),
info: (msg: string) => console.info(msg),
}).catch((err) => {
console.warn(`[ExchangeIndexCheck] Failed to verify indexes: ${(err as Error).message}`)
})
}
async shutdown() {
+14
View File
@@ -42,4 +42,18 @@ export default await Env.create(new URL('../', import.meta.url), {
// Security
HASH_KEY: Env.schema.string.optional(),
// Exchange Store
EXCHANGE_STORE_MAX_MESSAGES: Env.schema.number.optional(),
EXCHANGE_STORE_TTL_SECONDS: Env.schema.number.optional(),
EXCHANGE_STORE_SSE_INACTIVITY_TIMEOUT_MS: Env.schema.number.optional(),
EXCHANGE_STORE_SSE_ABSOLUTE_TIMEOUT_MS: Env.schema.number.optional(),
EXCHANGE_STORE_SSE_MAX_CONNECTIONS_PER_DEVICE: Env.schema.number.optional(),
EXCHANGE_STORE_SSE_RETRY_INTERVAL_MS: Env.schema.number.optional(),
EXCHANGE_STORE_RETRY_MAX_ATTEMPTS: Env.schema.number.optional(),
EXCHANGE_STORE_RETRY_BASE_DELAY_MS: Env.schema.number.optional(),
EXCHANGE_STORE_READ_TIMEOUT_MS: Env.schema.number.optional(),
FEATURE_EXCHANGE_PERSISTENCE: Env.schema.boolean.optional(),
FEATURE_SSE_ENABLED: Env.schema.boolean.optional(),
FEATURE_EXCHANGE_API_SOURCE: Env.schema.enum.optional(['mongodb', 'file'] as const),
})
+7
View File
@@ -10,6 +10,7 @@ const HealthController = () => import('#controllers/health_controller')
const PagesController = () => import('#controllers/pages_controller')
const CwmpController = () => import('#controllers/cwmp_controller')
const Tr069TraceController = () => import('#controllers/tr069_trace_controller')
const ExchangeController = () => import('#controllers/exchange_controller')
// CWMP/TR-069 endpoint (SOAP/XML) - uses dedicated middleware for XML parsing
router.post('/cwmp10', [CwmpController, 'handle']).use([middleware.cwmpLogger(), middleware.cwmp()])
@@ -17,6 +18,7 @@ router.post('/cwmp10', [CwmpController, 'handle']).use([middleware.cwmpLogger(),
// Health checks
router.get('/api/v1/checklife', [HealthController, 'checklife'])
router.get('/api/v1/checkservice', [HealthController, 'checkservice'])
router.get('/api/v1/health/tr069-exchanges', [HealthController, 'checkTr069Exchanges'])
// REST API routes
router
@@ -48,6 +50,11 @@ router
router.delete('/tr069-trace/:id', [Tr069TraceController, 'destroy'])
router.get('/tr069-trace/:id/log', [Tr069TraceController, 'showLog'])
// Exchange API (TR-069 Exchange Viewer)
router.get('/tr069-trace/:deviceId/exchanges', [ExchangeController, 'index'])
router.delete('/tr069-trace/:deviceId/exchanges', [ExchangeController, 'destroy'])
router.get('/tr069-trace/:deviceId/exchanges/stream', [ExchangeController, 'stream'])
// Unified Configuration API (v1)
router.get('/configurations', [ConfigurationsV1Controller, 'index'])
router.get('/configurations/tags', [ConfigurationsV1Controller, 'listTags'])
+508
View File
@@ -0,0 +1,508 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { EventEmitter } from 'node:events'
/**
* Clear Exchanges Flow Integration Test
*
* Validates the full flow:
* 1. Create exchanges for a device, verify they exist
* 2. Call DELETE endpoint, verify 204 response
* 3. Verify: exchange list is empty
* 4. Verify: SSE stream remains active and continues receiving new messages
*
* Validates: Requirements 8.1, 8.2, 8.3, 8.4, 8.5
*/
// --- Hoisted mocks ---
const loggerMock = vi.hoisted(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}))
vi.mock('@adonisjs/core/services/logger', () => ({
default: loggerMock,
}))
vi.mock('#start/env', () => ({
default: {
get: (_key: string, defaultValue?: any) => defaultValue,
},
}))
// In-memory store for exchanges
const exchangeStore = vi.hoisted(() => {
let exchanges: any[] = []
let seqCounter = 0
return {
exchanges,
getExchanges: () => exchanges,
setExchanges: (ex: any[]) => { exchanges.splice(0, exchanges.length, ...ex) },
resetCounter: () => { seqCounter = 0 },
getCounter: () => seqCounter,
incrementCounter: () => ++seqCounter,
clear: () => {
exchanges.splice(0, exchanges.length)
seqCounter = 0
},
}
})
vi.mock('#models/exchange', () => ({
default: {
getCollection: () => ({
insertOne: vi.fn().mockResolvedValue({ insertedId: 'mock-id' }),
}),
findByDevice: vi.fn().mockImplementation((deviceId: string, options?: any) => {
let results = exchangeStore.getExchanges().filter((e: any) => e.deviceId === deviceId)
if (options?.after) {
results = results.filter((e: any) => e.timestamp > options.after)
}
results.sort((a: any, b: any) => a.timestamp.localeCompare(b.timestamp))
const limit = options?.limit ?? 200
return Promise.resolve(results.slice(0, limit))
}),
findAfterSequence: vi.fn().mockImplementation((deviceId: string, seqNumber: number) => {
const results = exchangeStore.getExchanges()
.filter((e: any) => e.deviceId === deviceId && e.sequenceNumber > seqNumber)
.sort((a: any, b: any) => a.sequenceNumber - b.sequenceNumber)
return Promise.resolve(results)
}),
deleteByDevice: vi.fn().mockImplementation((deviceId: string) => {
const before = exchangeStore.getExchanges().length
const remaining = exchangeStore.getExchanges().filter((e: any) => e.deviceId !== deviceId)
exchangeStore.setExchanges(remaining)
return Promise.resolve(before - remaining.length)
}),
countByDevice: vi.fn().mockImplementation((deviceId: string) => {
return Promise.resolve(exchangeStore.getExchanges().filter((e: any) => e.deviceId === deviceId).length)
}),
deleteOldestBeyondCap: vi.fn().mockResolvedValue(undefined),
hasExchanges: vi.fn().mockImplementation((deviceId: string) => {
return Promise.resolve(exchangeStore.getExchanges().some((e: any) => e.deviceId === deviceId))
}),
getMetadata: vi.fn().mockImplementation((deviceId: string) => {
const deviceExchanges = exchangeStore.getExchanges().filter((e: any) => e.deviceId === deviceId)
if (deviceExchanges.length === 0) {
return Promise.resolve({ recordedFrom: null, recordedUntil: null, count: 0 })
}
const sorted = [...deviceExchanges].sort((a, b) => a.timestamp.localeCompare(b.timestamp))
return Promise.resolve({
recordedFrom: sorted[0].timestamp,
recordedUntil: sorted[sorted.length - 1].timestamp,
count: deviceExchanges.length,
})
}),
},
}))
vi.mock('#providers/mongodb_provider', () => ({
getMongoDb: () => ({
collection: () => ({
findOneAndUpdate: vi.fn().mockImplementation(() => {
return Promise.resolve({ seq: exchangeStore.incrementCounter() })
}),
}),
}),
}))
vi.mock('#config/exchange_store', () => ({
default: {
maxMessagesPerDevice: 5000,
ttlSeconds: 86400,
retry: { maxAttempts: 3, baseDelayMs: 10 },
featureFlags: { persistence: true, sseEnabled: true, apiSource: 'mongodb' },
sse: {
inactivityTimeoutMs: 300_000,
absoluteTimeoutMs: 7_200_000,
maxConnectionsPerDevice: 5,
retryIntervalMs: 3000,
},
},
}))
// Mock Tr069TraceService — device is always traced
const tracedDevices = vi.hoisted(() => new Set<string>())
vi.mock('#services/tr069_trace_service', () => ({
Tr069TraceService: {
getInstance: () => ({
isTraced: (deviceId: string) => tracedDevices.has(deviceId),
}),
},
}))
// --- Imports (after mocks) ---
import ExchangeController from '../../app/controllers/exchange_controller.js'
import { ExchangeStoreService } from '../../app/services/exchange_store_service.js'
import type { CwmpExchange } from '../../app/models/exchange.js'
describe('Clear Exchanges Flow (Integration)', () => {
const DEVICE_ID = 'test-device-clear-001'
let controller: ExchangeController
let storeService: ExchangeStoreService
beforeEach(() => {
vi.clearAllMocks()
exchangeStore.clear()
tracedDevices.clear()
tracedDevices.add(DEVICE_ID)
// Reset singleton to get a fresh instance
;(ExchangeStoreService as any).instance = undefined
storeService = ExchangeStoreService.getInstance()
controller = new ExchangeController()
})
afterEach(() => {
tracedDevices.clear()
})
/**
* Helper: create mock exchanges in the in-memory store
*/
function createExchanges(deviceId: string, count: number): CwmpExchange[] {
const exchanges: CwmpExchange[] = []
const baseTime = new Date('2025-01-15T10:00:00.000Z')
for (let i = 0; i < count; i++) {
const timestamp = new Date(baseTime.getTime() + i * 1000).toISOString()
const exchange: CwmpExchange = {
deviceId,
direction: i % 2 === 0 ? 'CPE→ACS' : 'ACS→CPE',
timestamp,
sessionId: `10.0.0.1:${50000 + i}->192.168.1.1:8080`,
cwmpMethod: i === 0 ? 'Inform' : i % 2 === 0 ? 'GetParameterValues' : 'GetParameterValuesResponse',
rawXml: `<soap:Envelope><cwmp:${i === 0 ? 'Inform' : 'GetParameterValues'}/></soap:Envelope>`,
sequenceNumber: i + 1,
createdAt: new Date(baseTime.getTime() + i * 1000),
}
exchanges.push(exchange)
}
exchangeStore.setExchanges([...exchangeStore.getExchanges(), ...exchanges])
return exchanges
}
/**
* Helper: create a mock HttpContext for testing
*/
function createMockContext(overrides: {
params?: Record<string, string>
qs?: Record<string, any>
headers?: Record<string, string>
} = {}) {
const responseData: { status?: number; body?: any } = {}
return {
params: overrides.params ?? { deviceId: DEVICE_ID },
request: {
qs: () => overrides.qs ?? {},
header: (name: string) => (overrides.headers ?? {})[name] ?? undefined,
request: new EventEmitter(),
id: () => 'test-correlation-id',
},
response: {
status: (code: number) => ({
json: (data: any) => {
responseData.status = code
responseData.body = data
return { status: code, body: data }
},
send: (data: any) => {
responseData.status = code
responseData.body = data
return { status: code, body: data }
},
}),
response: null as any, // for SSE — will be set per test
},
responseData,
}
}
// --- Test: Requirement 8.1 — Clear exchanges button (verified via DELETE endpoint) ---
it('should have exchanges present before clearing', async () => {
const created = createExchanges(DEVICE_ID, 5)
expect(created).toHaveLength(5)
const ctx = createMockContext()
const result = await controller.index(ctx as any)
expect(result.status).toBe(200)
expect(result.body.exchanges).toHaveLength(5)
expect(result.body.metadata.total).toBe(5)
expect(result.body.metadata.traceStatus).toBe('ACTIVE')
})
// --- Test: Requirement 8.4 — DELETE returns 204, empties exchange list ---
it('should return 204 on DELETE and empty the exchange list', async () => {
createExchanges(DEVICE_ID, 10)
// Verify exchanges exist
const getCtx = createMockContext()
const beforeResult = await controller.index(getCtx as any)
expect(beforeResult.status).toBe(200)
expect(beforeResult.body.exchanges).toHaveLength(10)
// Call DELETE endpoint
const deleteCtx = createMockContext()
const deleteResult = await controller.destroy(deleteCtx as any)
expect(deleteResult.status).toBe(204)
// Verify exchange list is now empty
// After deletion, device has no exchanges but is still traced → 200 with empty array
const afterCtx = createMockContext()
const afterResult = await controller.index(afterCtx as any)
expect(afterResult.status).toBe(200)
expect(afterResult.body.exchanges).toHaveLength(0)
expect(afterResult.body.metadata.total).toBe(0)
})
// --- Test: Requirement 8.4 — SSE stream remains active after clear ---
it('should keep SSE stream active after clearing exchanges and receive new messages', async () => {
createExchanges(DEVICE_ID, 3)
// Set up SSE stream (mock node response)
const sseWritten: string[] = []
let sseEnded = false
const mockNodeRes = {
writeHead: vi.fn(),
write: vi.fn().mockImplementation((chunk: string) => {
sseWritten.push(chunk)
}),
end: vi.fn().mockImplementation(() => { sseEnded = true }),
}
const mockNodeReq = new EventEmitter()
const sseCtx = {
params: { deviceId: DEVICE_ID },
request: {
qs: () => ({}),
header: (_name: string) => undefined,
request: mockNodeReq,
id: () => 'test-correlation-id',
},
response: {
status: (code: number) => ({
json: (data: any) => ({ status: code, body: data }),
}),
response: mockNodeRes,
},
}
// Connect SSE stream
await controller.stream(sseCtx as any)
// Verify SSE headers were set
expect(mockNodeRes.writeHead).toHaveBeenCalledWith(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
})
// SSE connected — verify initial retry interval was sent
expect(sseWritten.some((chunk) => chunk.includes('retry:'))).toBe(true)
// Clear the SSE written log for clarity
sseWritten.length = 0
// Now DELETE all exchanges
const deleteCtx = createMockContext()
await controller.destroy(deleteCtx as any)
// Verify SSE stream is still active (not ended)
expect(sseEnded).toBe(false)
// Emit a new exchange event via ExchangeStoreService (simulating a new CWMP message arriving)
const newExchange: CwmpExchange = {
deviceId: DEVICE_ID,
direction: 'CPE→ACS',
timestamp: new Date('2025-01-15T11:00:00.000Z').toISOString(),
sessionId: '10.0.0.1:60000->192.168.1.1:8080',
cwmpMethod: 'Inform',
rawXml: '<soap:Envelope><cwmp:Inform/></soap:Envelope>',
sequenceNumber: 100,
createdAt: new Date('2025-01-15T11:00:00.000Z'),
}
// Emit event through the ExchangeStoreService emitter
;(storeService as any).emitter.emit(`exchange:${DEVICE_ID}`, newExchange)
// Verify the SSE stream received the new message
expect(sseWritten.length).toBeGreaterThan(0)
const lastEvent = sseWritten[sseWritten.length - 1]
expect(lastEvent).toContain('id: 100')
expect(lastEvent).toContain('"cwmpMethod":"Inform"')
expect(lastEvent).toContain('"deviceId":"test-device-clear-001"')
// Clean up — simulate client disconnect
mockNodeReq.emit('close')
})
// --- Test: Requirement 8.3 — If user cancels, exchanges remain unchanged ---
it('should preserve exchange log if DELETE is not called (cancel scenario)', async () => {
createExchanges(DEVICE_ID, 7)
// Verify exchanges exist
const ctx = createMockContext()
const result = await controller.index(ctx as any)
expect(result.status).toBe(200)
expect(result.body.exchanges).toHaveLength(7)
// User "cancels" — no DELETE call made — exchanges remain
const verifyCtx = createMockContext()
const verifyResult = await controller.index(verifyCtx as any)
expect(verifyResult.status).toBe(200)
expect(verifyResult.body.exchanges).toHaveLength(7)
})
// --- Test: Requirement 8.5 — Server failure to clear should not disrupt existing data ---
it('should preserve exchange data if DELETE fails on the server', async () => {
createExchanges(DEVICE_ID, 5)
// Temporarily make deleteByDevice fail
const ExchangeModel = (await import('../../app/models/exchange.js')).default
const originalDelete = ExchangeModel.deleteByDevice
;(ExchangeModel.deleteByDevice as any) = vi.fn().mockRejectedValue(new Error('MongoDB connection lost'))
// Call DELETE — it should throw
const deleteCtx = createMockContext()
await expect(controller.destroy(deleteCtx as any)).rejects.toThrow('MongoDB connection lost')
// Restore
;(ExchangeModel.deleteByDevice as any) = originalDelete
// Verify exchanges are still present
const getCtx = createMockContext()
const result = await controller.index(getCtx as any)
expect(result.status).toBe(200)
expect(result.body.exchanges).toHaveLength(5)
})
// --- Test: Requirement 8.4 — After clear, new SSE messages still arrive ---
it('should continue receiving multiple SSE messages after clearing exchanges', async () => {
createExchanges(DEVICE_ID, 2)
// Set up SSE stream
const sseWritten: string[] = []
const mockNodeRes = {
writeHead: vi.fn(),
write: vi.fn().mockImplementation((chunk: string) => { sseWritten.push(chunk) }),
end: vi.fn(),
}
const mockNodeReq = new EventEmitter()
const sseCtx = {
params: { deviceId: DEVICE_ID },
request: {
qs: () => ({}),
header: (_name: string) => undefined,
request: mockNodeReq,
id: () => 'test-correlation-id',
},
response: {
status: (code: number) => ({
json: (data: any) => ({ status: code, body: data }),
}),
response: mockNodeRes,
},
}
await controller.stream(sseCtx as any)
sseWritten.length = 0
// DELETE exchanges
const deleteCtx = createMockContext()
await controller.destroy(deleteCtx as any)
// Emit multiple new exchanges
const newExchanges: CwmpExchange[] = [
{
deviceId: DEVICE_ID,
direction: 'CPE→ACS',
timestamp: '2025-01-15T12:00:00.000Z',
sessionId: '10.0.0.1:70000->192.168.1.1:8080',
cwmpMethod: 'Inform',
rawXml: '<soap:Envelope><cwmp:Inform/></soap:Envelope>',
sequenceNumber: 201,
createdAt: new Date('2025-01-15T12:00:00.000Z'),
},
{
deviceId: DEVICE_ID,
direction: 'ACS→CPE',
timestamp: '2025-01-15T12:00:01.000Z',
sessionId: '10.0.0.1:70000->192.168.1.1:8080',
cwmpMethod: 'GetParameterValues',
rawXml: '<soap:Envelope><cwmp:GetParameterValues/></soap:Envelope>',
sequenceNumber: 202,
createdAt: new Date('2025-01-15T12:00:01.000Z'),
},
{
deviceId: DEVICE_ID,
direction: 'CPE→ACS',
timestamp: '2025-01-15T12:00:02.000Z',
sessionId: '10.0.0.1:70000->192.168.1.1:8080',
cwmpMethod: 'GetParameterValuesResponse',
rawXml: '<soap:Envelope><cwmp:GetParameterValuesResponse/></soap:Envelope>',
sequenceNumber: 203,
createdAt: new Date('2025-01-15T12:00:02.000Z'),
},
]
for (const exchange of newExchanges) {
;(storeService as any).emitter.emit(`exchange:${DEVICE_ID}`, exchange)
}
// Verify all 3 new messages were received via SSE
expect(sseWritten).toHaveLength(3)
expect(sseWritten[0]).toContain('id: 201')
expect(sseWritten[0]).toContain('"cwmpMethod":"Inform"')
expect(sseWritten[1]).toContain('id: 202')
expect(sseWritten[1]).toContain('"cwmpMethod":"GetParameterValues"')
expect(sseWritten[2]).toContain('id: 203')
expect(sseWritten[2]).toContain('"cwmpMethod":"GetParameterValuesResponse"')
// Clean up
mockNodeReq.emit('close')
})
// --- Test: Requirement 8.2 / 8.4 — Confirm and clear with metadata reset ---
it('should reset metadata after clearing exchanges', async () => {
createExchanges(DEVICE_ID, 20)
// Verify metadata before clear
const beforeCtx = createMockContext()
const beforeResult = await controller.index(beforeCtx as any)
expect(beforeResult.body.metadata.total).toBe(20)
expect(beforeResult.body.metadata.recordedFrom).not.toBeNull()
expect(beforeResult.body.metadata.recordedUntil).not.toBeNull()
// DELETE
const deleteCtx = createMockContext()
const deleteResult = await controller.destroy(deleteCtx as any)
expect(deleteResult.status).toBe(204)
// Verify metadata is reset (device still traced → 200 with empty + null metadata)
const afterCtx = createMockContext()
const afterResult = await controller.index(afterCtx as any)
expect(afterResult.status).toBe(200)
expect(afterResult.body.exchanges).toHaveLength(0)
expect(afterResult.body.metadata.total).toBe(0)
expect(afterResult.body.metadata.recordedFrom).toBeNull()
expect(afterResult.body.metadata.recordedUntil).toBeNull()
expect(afterResult.body.metadata.traceStatus).toBe('ACTIVE')
})
})
@@ -0,0 +1,699 @@
/**
* Integration test: Full Exchange Lifecycle
*
* Tests the complete flow:
* SOAP capture → persist to MongoDB → SSE event emitted → data integrity verified
*
* This is a unit-level integration test using mocks (not a real MongoDB connection).
* We mock the MongoDB layer but verify the data flows through:
* ExchangeStoreService → ExchangeModel → EventEmitter correctly.
*
* Validates: Requirements 1.1, 1.7, 6.1, 6.2
*/
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
// ─── MongoDB Mocks ──────────────────────────────────────────────────────────────
let sequenceCounter = 0
const mockInsertOne = vi.fn().mockImplementation(async () => ({ insertedId: 'mock-id' }))
const mockCountDocuments = vi.fn().mockResolvedValue(0)
const mockDeleteMany = vi.fn().mockResolvedValue({ deletedCount: 0 })
const mockFind = vi.fn().mockReturnValue({
sort: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
project: vi.fn().mockReturnThis(),
toArray: vi.fn().mockResolvedValue([]),
})
const mockFindOneAndUpdate = vi.fn().mockImplementation(async () => {
sequenceCounter++
return { seq: sequenceCounter }
})
vi.mock('#providers/mongodb_provider', () => ({
getMongoDb: () => ({
collection: (name: string) => {
if (name === 'cwmp_exchange_counters') {
return { findOneAndUpdate: mockFindOneAndUpdate }
}
return {
insertOne: mockInsertOne,
countDocuments: mockCountDocuments,
deleteMany: mockDeleteMany,
find: mockFind,
findOne: vi.fn().mockResolvedValue(null),
}
},
}),
}))
vi.mock('#models/exchange', () => ({
default: {
collectionName: 'cwmp_exchanges',
getCollection: () => ({
insertOne: mockInsertOne,
countDocuments: mockCountDocuments,
deleteMany: mockDeleteMany,
find: mockFind,
findOne: vi.fn().mockResolvedValue(null),
}),
countByDevice: vi.fn().mockResolvedValue(0),
deleteOldestBeyondCap: vi.fn().mockResolvedValue(undefined),
},
}))
vi.mock('#config/exchange_store', () => ({
default: {
maxMessagesPerDevice: 5000,
ttlSeconds: 86400,
sse: {
inactivityTimeoutMs: 300_000,
absoluteTimeoutMs: 7_200_000,
maxConnectionsPerDevice: 5,
retryIntervalMs: 3000,
},
retry: {
maxAttempts: 3,
baseDelayMs: 1, // 1ms for fast tests
},
featureFlags: {
persistence: true,
sseEnabled: true,
apiSource: 'mongodb',
},
},
}))
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
warn: vi.fn(),
error: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}))
import { ExchangeStoreService } from '../../app/services/exchange_store_service.js'
import type { CwmpExchange } from '../../app/models/exchange.js'
// ─── Test Suite ─────────────────────────────────────────────────────────────────
describe('Exchange Lifecycle Integration', () => {
let service: ExchangeStoreService
beforeEach(() => {
vi.clearAllMocks()
sequenceCounter = 0
// Reset singleton for clean state
;(ExchangeStoreService as any).instance = undefined
service = ExchangeStoreService.getInstance()
})
afterEach(() => {
;(ExchangeStoreService as any).instance = undefined
})
describe('Full lifecycle: SOAP capture → persist → SSE event', () => {
it('persists a CPE→ACS Inform and emits SSE event with correct data', async () => {
const soapXml = `<?xml version="1.0" encoding="UTF-8"?>
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Body>
<cwmp:Inform>
<DeviceId>
<Manufacturer>Sagemcom</Manufacturer>
<OUI>F8B7FF</OUI>
<ProductClass>Livebox 4</ProductClass>
<SerialNumber>LB4-TEST-001</SerialNumber>
</DeviceId>
</cwmp:Inform>
</soap-env:Body>
</soap-env:Envelope>`
const deviceId = 'test-device-001'
const receivedEvents: CwmpExchange[] = []
// Subscribe to SSE events before persisting
const listener = (exchange: CwmpExchange) => {
receivedEvents.push(exchange)
}
service.subscribe(deviceId, listener)
// Simulate SOAP capture → persist (what CwmpLoggerMiddleware does)
const result = await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: '10.0.0.1:54321->192.168.1.1:8080',
rawXml: soapXml,
})
// Verify persistence result
expect(result).not.toBeNull()
expect(result!.deviceId).toBe(deviceId)
expect(result!.direction).toBe('CPE→ACS')
expect(result!.cwmpMethod).toBe('Inform')
expect(result!.rawXml).toBe(soapXml)
expect(result!.sequenceNumber).toBe(1)
expect(result!.sessionId).toBe('10.0.0.1:54321->192.168.1.1:8080')
expect(result!.timestamp).toBeDefined()
// Verify MongoDB insertOne was called with correct document
expect(mockInsertOne).toHaveBeenCalledTimes(1)
const insertedDoc = mockInsertOne.mock.calls[0][0]
expect(insertedDoc.deviceId).toBe(deviceId)
expect(insertedDoc.direction).toBe('CPE→ACS')
expect(insertedDoc.cwmpMethod).toBe('Inform')
expect(insertedDoc.rawXml).toBe(soapXml)
expect(insertedDoc.sequenceNumber).toBe(1)
// Verify SSE event was emitted with the same data
expect(receivedEvents).toHaveLength(1)
expect(receivedEvents[0].deviceId).toBe(deviceId)
expect(receivedEvents[0].direction).toBe('CPE→ACS')
expect(receivedEvents[0].cwmpMethod).toBe('Inform')
expect(receivedEvents[0].rawXml).toBe(soapXml)
expect(receivedEvents[0].sequenceNumber).toBe(1)
service.unsubscribe(deviceId, listener)
})
it('persists an ACS→CPE GetParameterValuesResponse and emits SSE event', async () => {
const responseXml = `<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Body>
<cwmp:GetParameterValuesResponse>
<ParameterList>
<ParameterValueStruct>
<Name>Device.DeviceInfo.SoftwareVersion</Name>
<Value>SR40_sip-fr-4.62.0</Value>
</ParameterValueStruct>
</ParameterList>
</cwmp:GetParameterValuesResponse>
</soap-env:Body>
</soap-env:Envelope>`
const deviceId = 'test-device-002'
const receivedEvents: CwmpExchange[] = []
const listener = (exchange: CwmpExchange) => receivedEvents.push(exchange)
service.subscribe(deviceId, listener)
const result = await service.persistExchange({
deviceId,
direction: 'ACS→CPE',
sessionId: '192.168.1.1:8080->10.0.0.1:54321',
rawXml: responseXml,
})
// Verify data integrity across all steps
expect(result).not.toBeNull()
expect(result!.deviceId).toBe(deviceId)
expect(result!.direction).toBe('ACS→CPE')
expect(result!.cwmpMethod).toBe('GetParameterValuesResponse')
expect(result!.rawXml).toBe(responseXml)
// Verify SSE event matches persisted document
expect(receivedEvents).toHaveLength(1)
expect(receivedEvents[0]).toEqual(result)
service.unsubscribe(deviceId, listener)
})
it('handles a complete session: CPE→ACS Inform + ACS→CPE InformResponse', async () => {
const deviceId = 'test-device-session'
const receivedEvents: CwmpExchange[] = []
const listener = (exchange: CwmpExchange) => receivedEvents.push(exchange)
service.subscribe(deviceId, listener)
// Step 1: CPE sends Inform
const informXml = `<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Body><cwmp:Inform><DeviceId/></cwmp:Inform></soap-env:Body>
</soap-env:Envelope>`
const inform = await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: '10.0.0.1:5000->192.168.1.1:8080',
rawXml: informXml,
})
// Step 2: ACS responds with InformResponse
const informResponseXml = `<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Body><cwmp:InformResponse><MaxEnvelopes>1</MaxEnvelopes></cwmp:InformResponse></soap-env:Body>
</soap-env:Envelope>`
const informResponse = await service.persistExchange({
deviceId,
direction: 'ACS→CPE',
sessionId: '10.0.0.1:5000->192.168.1.1:8080',
rawXml: informResponseXml,
})
// Verify sequence numbers are monotonically increasing
expect(inform!.sequenceNumber).toBe(1)
expect(informResponse!.sequenceNumber).toBe(2)
// Verify all events were emitted in order
expect(receivedEvents).toHaveLength(2)
expect(receivedEvents[0].cwmpMethod).toBe('Inform')
expect(receivedEvents[0].direction).toBe('CPE→ACS')
expect(receivedEvents[0].sequenceNumber).toBe(1)
expect(receivedEvents[1].cwmpMethod).toBe('InformResponse')
expect(receivedEvents[1].direction).toBe('ACS→CPE')
expect(receivedEvents[1].sequenceNumber).toBe(2)
// Verify data integrity: rawXml preserved at each step
expect(receivedEvents[0].rawXml).toBe(informXml)
expect(receivedEvents[1].rawXml).toBe(informResponseXml)
service.unsubscribe(deviceId, listener)
})
})
describe('Data integrity verification', () => {
it('preserves deviceId through the entire lifecycle', async () => {
const deviceId = 'unique-device-id-αβγ'
const receivedEvents: CwmpExchange[] = []
const listener = (exchange: CwmpExchange) => receivedEvents.push(exchange)
service.subscribe(deviceId, listener)
const result = await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: 'sess-1',
rawXml: '<cwmp:Inform/>',
})
// Verify deviceId preserved: service return
expect(result!.deviceId).toBe(deviceId)
// Verify deviceId preserved: MongoDB insert
expect(mockInsertOne.mock.calls[0][0].deviceId).toBe(deviceId)
// Verify deviceId preserved: SSE event
expect(receivedEvents[0].deviceId).toBe(deviceId)
service.unsubscribe(deviceId, listener)
})
it('preserves direction through the entire lifecycle', async () => {
const deviceId = 'direction-test'
const receivedEvents: CwmpExchange[] = []
const listener = (exchange: CwmpExchange) => receivedEvents.push(exchange)
service.subscribe(deviceId, listener)
// Test CPE→ACS direction
await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: 's1',
rawXml: '<cwmp:Inform/>',
})
// Test ACS→CPE direction
await service.persistExchange({
deviceId,
direction: 'ACS→CPE',
sessionId: 's1',
rawXml: '<cwmp:GetParameterValues/>',
})
expect(receivedEvents[0].direction).toBe('CPE→ACS')
expect(receivedEvents[1].direction).toBe('ACS→CPE')
// Verify in MongoDB writes
expect(mockInsertOne.mock.calls[0][0].direction).toBe('CPE→ACS')
expect(mockInsertOne.mock.calls[1][0].direction).toBe('ACS→CPE')
service.unsubscribe(deviceId, listener)
})
it('preserves cwmpMethod extraction through the entire lifecycle', async () => {
const deviceId = 'method-test'
const receivedEvents: CwmpExchange[] = []
const listener = (exchange: CwmpExchange) => receivedEvents.push(exchange)
service.subscribe(deviceId, listener)
const methods = [
{ xml: '<cwmp:Inform/>', expected: 'Inform' },
{ xml: '<cwmp:GetParameterValues/>', expected: 'GetParameterValues' },
{ xml: '<cwmp:SetParameterValuesResponse/>', expected: 'SetParameterValuesResponse' },
{ xml: '', expected: 'Empty' },
{ xml: '<no-cwmp-here/>', expected: 'Unknown' },
]
for (const { xml } of methods) {
await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: 'sess',
rawXml: xml,
})
}
// Verify each extracted method in the SSE events
expect(receivedEvents[0].cwmpMethod).toBe('Inform')
expect(receivedEvents[1].cwmpMethod).toBe('GetParameterValues')
expect(receivedEvents[2].cwmpMethod).toBe('SetParameterValuesResponse')
expect(receivedEvents[3].cwmpMethod).toBe('Empty')
expect(receivedEvents[4].cwmpMethod).toBe('Unknown')
// Verify same values in MongoDB inserts
for (let i = 0; i < methods.length; i++) {
expect(mockInsertOne.mock.calls[i][0].cwmpMethod).toBe(methods[i].expected)
}
service.unsubscribe(deviceId, listener)
})
it('preserves rawXml without modification through the entire lifecycle', async () => {
const deviceId = 'rawxml-test'
const receivedEvents: CwmpExchange[] = []
const listener = (exchange: CwmpExchange) => receivedEvents.push(exchange)
service.subscribe(deviceId, listener)
const complexXml = `<?xml version="1.0" encoding="UTF-8"?>
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/" xmlns:soap-enc="http://schemas.xmlsoap.org/soap/encoding/" xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Header>
<cwmp:ID soap-env:mustUnderstand="1">123456</cwmp:ID>
</soap-env:Header>
<soap-env:Body>
<cwmp:Inform>
<DeviceId>
<Manufacturer>Sagemcom</Manufacturer>
<OUI>F8B7FF</OUI>
<ProductClass>Livebox 4</ProductClass>
<SerialNumber>LB4-TEST-001</SerialNumber>
</DeviceId>
<Event>
<EventStruct>
<EventCode>0 BOOTSTRAP</EventCode>
<CommandKey></CommandKey>
</EventStruct>
</Event>
<ParameterList>
<ParameterValueStruct>
<Name>Device.DeviceInfo.SoftwareVersion</Name>
<Value xsi:type="xsd:string">4.62.0</Value>
</ParameterValueStruct>
</ParameterList>
</cwmp:Inform>
</soap-env:Body>
</soap-env:Envelope>`
const result = await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: '10.0.0.1:1234->192.168.1.1:8080',
rawXml: complexXml,
})
// Verify rawXml preserved exactly in service result
expect(result!.rawXml).toBe(complexXml)
// Verify rawXml preserved exactly in MongoDB insert
expect(mockInsertOne.mock.calls[0][0].rawXml).toBe(complexXml)
// Verify rawXml preserved exactly in SSE event
expect(receivedEvents[0].rawXml).toBe(complexXml)
service.unsubscribe(deviceId, listener)
})
})
describe('SSE event emission and subscription', () => {
it('emits events only to subscribers of the correct device', async () => {
const eventsA: CwmpExchange[] = []
const eventsB: CwmpExchange[] = []
const listenerA = (e: CwmpExchange) => eventsA.push(e)
const listenerB = (e: CwmpExchange) => eventsB.push(e)
service.subscribe('device-A', listenerA)
service.subscribe('device-B', listenerB)
await service.persistExchange({
deviceId: 'device-A',
direction: 'CPE→ACS',
sessionId: 's1',
rawXml: '<cwmp:Inform/>',
})
expect(eventsA).toHaveLength(1)
expect(eventsB).toHaveLength(0)
await service.persistExchange({
deviceId: 'device-B',
direction: 'ACS→CPE',
sessionId: 's2',
rawXml: '<cwmp:GetParameterValuesResponse/>',
})
expect(eventsA).toHaveLength(1)
expect(eventsB).toHaveLength(1)
service.unsubscribe('device-A', listenerA)
service.unsubscribe('device-B', listenerB)
})
it('stops emitting after unsubscribe', async () => {
const deviceId = 'unsub-test'
const events: CwmpExchange[] = []
const listener = (e: CwmpExchange) => events.push(e)
service.subscribe(deviceId, listener)
await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: 's1',
rawXml: '<cwmp:Inform/>',
})
expect(events).toHaveLength(1)
service.unsubscribe(deviceId, listener)
await service.persistExchange({
deviceId,
direction: 'ACS→CPE',
sessionId: 's1',
rawXml: '<cwmp:InformResponse/>',
})
expect(events).toHaveLength(1) // No new event after unsubscribe
})
it('supports multiple subscribers for the same device', async () => {
const deviceId = 'multi-sub'
const events1: CwmpExchange[] = []
const events2: CwmpExchange[] = []
const listener1 = (e: CwmpExchange) => events1.push(e)
const listener2 = (e: CwmpExchange) => events2.push(e)
service.subscribe(deviceId, listener1)
service.subscribe(deviceId, listener2)
await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: 's1',
rawXml: '<cwmp:Inform/>',
})
expect(events1).toHaveLength(1)
expect(events2).toHaveLength(1)
expect(events1[0]).toEqual(events2[0])
service.unsubscribe(deviceId, listener1)
service.unsubscribe(deviceId, listener2)
})
})
describe('Retry behavior for critical vs non-critical messages (Req 1.7)', () => {
it('retries for critical Inform message on MongoDB failure — never interrupts CWMP', async () => {
// Reset mock entirely and make all inserts fail
mockInsertOne.mockReset()
mockInsertOne.mockRejectedValue(new Error('MongoDB write failed'))
const deviceId = 'retry-critical-test'
const events: CwmpExchange[] = []
const listener = (e: CwmpExchange) => events.push(e)
service.subscribe(deviceId, listener)
const result = await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: 's1',
rawXml: '<cwmp:Inform/>',
})
// Service never throws and still returns the exchange (fire-and-forget persistence)
// The CWMP session must NOT be interrupted regardless of persistence outcome
expect(result).not.toBeNull()
expect(result!.cwmpMethod).toBe('Inform')
expect(result!.deviceId).toBe(deviceId)
// Verify retries happened: 1 initial attempt + 3 retries = 4 total calls
expect(mockInsertOne).toHaveBeenCalledTimes(4)
// SSE event is still emitted (subscribers get notified even on write failure)
expect(events).toHaveLength(1)
expect(events[0].cwmpMethod).toBe('Inform')
service.unsubscribe(deviceId, listener)
})
it('does NOT retry for non-critical Empty message on MongoDB failure', async () => {
// Reset mock entirely and make insert fail
mockInsertOne.mockReset()
mockInsertOne.mockRejectedValue(new Error('MongoDB write failed'))
const deviceId = 'retry-noncritical-test'
const events: CwmpExchange[] = []
const listener = (e: CwmpExchange) => events.push(e)
service.subscribe(deviceId, listener)
const result = await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: 's1',
rawXml: '', // Empty → non-critical
})
// Service still returns exchange (fire-and-forget), CWMP never interrupted
expect(result).not.toBeNull()
expect(result!.cwmpMethod).toBe('Empty')
// Only 1 attempt — non-critical messages are discarded immediately on failure
expect(mockInsertOne).toHaveBeenCalledTimes(1)
// SSE event still emitted
expect(events).toHaveLength(1)
service.unsubscribe(deviceId, listener)
})
it('critical messages get 3 retries while non-critical get 0 retries', async () => {
// This verifies the retry classification difference:
// Critical: 1 initial + 3 retries = 4 attempts
// Non-critical: 1 initial + 0 retries = 1 attempt
mockInsertOne.mockReset()
mockInsertOne.mockRejectedValue(new Error('MongoDB down'))
// Test critical method (Inform)
await service.persistExchange({
deviceId: 'retry-class-test',
direction: 'CPE→ACS',
sessionId: 's1',
rawXml: '<cwmp:Inform/>',
})
const criticalAttempts = mockInsertOne.mock.calls.length
expect(criticalAttempts).toBe(4) // 1 initial + 3 retries
// Reset and test non-critical method (Unknown)
mockInsertOne.mockClear()
await service.persistExchange({
deviceId: 'retry-class-test',
direction: 'CPE→ACS',
sessionId: 's1',
rawXml: '<no-cwmp-element/>',
})
const nonCriticalAttempts = mockInsertOne.mock.calls.length
expect(nonCriticalAttempts).toBe(1) // Only 1 attempt, no retries
})
})
describe('Sequence number assignment (Req 1.1)', () => {
it('assigns monotonically increasing sequence numbers across multiple messages', async () => {
const deviceId = 'seq-test'
const events: CwmpExchange[] = []
const listener = (e: CwmpExchange) => events.push(e)
service.subscribe(deviceId, listener)
for (let i = 0; i < 5; i++) {
await service.persistExchange({
deviceId,
direction: i % 2 === 0 ? 'CPE→ACS' : 'ACS→CPE',
sessionId: 'sess',
rawXml: `<cwmp:Inform/>`,
})
}
expect(events).toHaveLength(5)
for (let i = 0; i < 5; i++) {
expect(events[i].sequenceNumber).toBe(i + 1)
}
// Verify atomic increment was called for each message
expect(mockFindOneAndUpdate).toHaveBeenCalledTimes(5)
service.unsubscribe(deviceId, listener)
})
it('uses device-scoped sequence numbers (separate counters per device)', async () => {
const eventsA: CwmpExchange[] = []
const eventsB: CwmpExchange[] = []
const listenerA = (e: CwmpExchange) => eventsA.push(e)
const listenerB = (e: CwmpExchange) => eventsB.push(e)
service.subscribe('dev-X', listenerA)
service.subscribe('dev-Y', listenerB)
await service.persistExchange({
deviceId: 'dev-X',
direction: 'CPE→ACS',
sessionId: 's1',
rawXml: '<cwmp:Inform/>',
})
await service.persistExchange({
deviceId: 'dev-Y',
direction: 'CPE→ACS',
sessionId: 's2',
rawXml: '<cwmp:Inform/>',
})
// Verify the counter collection is called with device-scoped _id
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'dev-X' },
{ $inc: { seq: 1 } },
{ upsert: true, returnDocument: 'after' }
)
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'dev-Y' },
{ $inc: { seq: 1 } },
{ upsert: true, returnDocument: 'after' }
)
service.unsubscribe('dev-X', listenerA)
service.unsubscribe('dev-Y', listenerB)
})
})
describe('CWMP session non-blocking guarantee', () => {
it('never throws even if MongoDB sequence counter is unavailable', async () => {
mockFindOneAndUpdate.mockRejectedValue(new Error('MongoDB unavailable'))
const deviceId = 'error-test'
// Should not throw — CWMP session must never be interrupted
const result = await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: 's1',
rawXml: '<cwmp:Inform/>',
})
expect(result).toBeNull()
})
it('never throws even if MongoDB insertOne is unavailable', async () => {
mockInsertOne.mockRejectedValue(new Error('MongoDB insertOne failed'))
const deviceId = 'insert-error-test'
// Should not throw — returns null
const result = await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: 's1',
rawXml: '<no-cwmp/>', // Unknown → non-critical, no retries
})
expect(result).toBeNull()
})
})
})
+338
View File
@@ -0,0 +1,338 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
/**
* Integration test: Message cap enforcement
* Validates: Requirements 1.4
*
* Verifies that when a device accumulates more than 5000 messages,
* the oldest messages are deleted and only the 5000 most recent remain.
*
* This is a unit-level integration test using mocks (not a real MongoDB).
* It mocks MongoDB operations but verifies the correct sequence of calls.
*/
// ─── Hoisted Mocks ────────────────────────────────────────────────────────────
const loggerMock = vi.hoisted(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}))
const insertOneMock = vi.hoisted(() => vi.fn().mockResolvedValue({ insertedId: 'mock-id' }))
const countDocumentsMock = vi.hoisted(() => vi.fn())
const findMock = vi.hoisted(() => vi.fn())
const deleteManyMock = vi.hoisted(() => vi.fn().mockResolvedValue({ deletedCount: 1 }))
const findOneAndUpdateMock = vi.hoisted(() => vi.fn())
// Track sequence numbers per device
let sequenceCounter: number
vi.mock('@adonisjs/core/services/logger', () => ({
default: loggerMock,
}))
vi.mock('#start/env', () => ({
default: {
get: (key: string, defaultValue?: any) => defaultValue,
},
}))
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',
},
},
}))
vi.mock('#models/exchange', () => ({
default: {
getCollection: () => ({
insertOne: insertOneMock,
countDocuments: countDocumentsMock,
find: findMock,
deleteMany: deleteManyMock,
}),
countByDevice: async (deviceId: string) => {
return countDocumentsMock(deviceId)
},
deleteOldestBeyondCap: async (deviceId: string, maxCount: number) => {
const count = await countDocumentsMock(deviceId)
if (count <= maxCount) return
const toDelete = count - maxCount
// Simulate finding oldest documents
const oldest = Array.from({ length: toDelete }, (_, i) => ({
sequenceNumber: i + 1,
}))
if (oldest.length === 0) return
const maxSeqToDelete = oldest[oldest.length - 1].sequenceNumber
await deleteManyMock({
deviceId,
sequenceNumber: { $lte: maxSeqToDelete },
})
},
},
}))
import { ExchangeStoreService } from '../../app/services/exchange_store_service.js'
// ─── Test Suite ───────────────────────────────────────────────────────────────
describe('Integration: Message Cap Enforcement', () => {
let service: ExchangeStoreService
beforeEach(() => {
vi.clearAllMocks()
;(ExchangeStoreService as any).instance = undefined
service = ExchangeStoreService.getInstance()
sequenceCounter = 0
// Each call to findOneAndUpdate increments and returns the next sequence number
findOneAndUpdateMock.mockImplementation(() => {
sequenceCounter++
return Promise.resolve({ seq: sequenceCounter })
})
})
it('enforces cap: inserting 5001 messages results in oldest being deleted and count = 5000', async () => {
const deviceId = 'device-cap-test'
const totalMessages = 5001
const maxCap = 5000
// Track the state: as messages are inserted, count grows
// After each insert, enforceMessageCap is called
// We simulate: for the first 5000 inserts, count <= 5000 so no delete
// On the 5001st insert, count is 5001 so deleteOldestBeyondCap is called
let currentCount = 0
countDocumentsMock.mockImplementation((_filter?: any) => {
// Return current count (updated after each insert)
return Promise.resolve(currentCount)
})
// Track all deleteMany calls to verify which messages were deleted
const deleteManyCalls: any[] = []
deleteManyMock.mockImplementation((filter: any) => {
deleteManyCalls.push(filter)
// After delete, current count should decrease
currentCount = maxCap
return Promise.resolve({ deletedCount: 1 })
})
// Mock find for deleteOldestBeyondCap: returns the oldest documents to delete
findMock.mockImplementation(() => ({
sort: () => ({
limit: (n: number) => ({
project: () => ({
toArray: () =>
Promise.resolve(
Array.from({ length: n }, (_, i) => ({
sequenceNumber: i + 1,
}))
),
}),
}),
}),
}))
// Simulate inserting 5001 messages
for (let i = 0; i < totalMessages; i++) {
currentCount = i + 1 // After this insert, count is i+1
await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: `session-${i}`,
rawXml: `<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0"><soap-env:Body><cwmp:Inform><DeviceId>${i}</DeviceId></cwmp:Inform></soap-env:Body></soap-env:Envelope>`,
})
}
// Verify: insertOne was called 5001 times (all messages persisted first)
expect(insertOneMock).toHaveBeenCalledTimes(totalMessages)
// Verify: countByDevice (via countDocuments in the mock) was called
// Each insert triggers enforceMessageCap which calls countByDevice
expect(countDocumentsMock).toHaveBeenCalled()
// Verify: deleteMany was called when count exceeded 5000
// The 5001st message triggers a delete
expect(deleteManyMock).toHaveBeenCalled()
// Verify: after enforcement, the effective count is 5000 (capped)
// The last countDocumentsMock returned 5001, which triggered the delete
// After delete, count became 5000
expect(currentCount).toBe(maxCap)
// Verify: the deletion targeted the oldest message (sequenceNumber <= 1)
const lastDeleteCall = deleteManyCalls[deleteManyCalls.length - 1]
expect(lastDeleteCall).toHaveProperty('deviceId', deviceId)
expect(lastDeleteCall).toHaveProperty('sequenceNumber')
expect(lastDeleteCall.sequenceNumber.$lte).toBe(1)
})
it('verifies correct call sequence: countByDevice is called before deleteOldestBeyondCap', async () => {
const deviceId = 'device-sequence-test'
const callOrder: string[] = []
// Track call order
countDocumentsMock.mockImplementation((_filter?: any) => {
callOrder.push('countByDevice')
return Promise.resolve(5001) // Always over cap for this test
})
deleteManyMock.mockImplementation((filter: any) => {
callOrder.push('deleteOldestBeyondCap')
return Promise.resolve({ deletedCount: 1 })
})
findMock.mockImplementation(() => ({
sort: () => ({
limit: (n: number) => ({
project: () => ({
toArray: () =>
Promise.resolve([{ sequenceNumber: 1 }]),
}),
}),
}),
}))
await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: 'session-test',
rawXml: '<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0"><soap-env:Body><cwmp:Inform/></soap-env:Body></soap-env:Envelope>',
})
// Verify call sequence: count is checked BEFORE delete
const countIndex = callOrder.indexOf('countByDevice')
const deleteIndex = callOrder.indexOf('deleteOldestBeyondCap')
expect(countIndex).toBeGreaterThanOrEqual(0)
expect(deleteIndex).toBeGreaterThan(countIndex)
})
it('retains the most recent messages by timestamp after cap enforcement', async () => {
const deviceId = 'device-recent-test'
const timestamps: string[] = []
// Generate 5001 timestamps in ascending order
const baseTime = new Date('2025-01-15T10:00:00.000Z')
for (let i = 0; i < 5001; i++) {
timestamps.push(new Date(baseTime.getTime() + i * 1000).toISOString())
}
let insertedDocs: any[] = []
insertOneMock.mockImplementation((doc: any) => {
insertedDocs.push({ ...doc })
return Promise.resolve({ insertedId: `id-${doc.sequenceNumber}` })
})
// After 5001 inserts, count exceeds cap
countDocumentsMock.mockImplementation((_filter?: any) => {
return Promise.resolve(insertedDocs.length)
})
// Track what gets deleted
let deletedMaxSeq = 0
deleteManyMock.mockImplementation((filter: any) => {
deletedMaxSeq = filter.sequenceNumber?.$lte || 0
return Promise.resolve({ deletedCount: 1 })
})
findMock.mockImplementation(() => ({
sort: () => ({
limit: (n: number) => ({
project: () => ({
toArray: () => {
// Return the oldest n documents (lowest sequence numbers)
const oldest = insertedDocs
.slice(0, n)
.map((d) => ({ sequenceNumber: d.sequenceNumber }))
return Promise.resolve(oldest)
},
}),
}),
}),
}))
// Override Date to control timestamps
let dateIndex = 0
const originalDateNow = Date.prototype.toISOString
vi.spyOn(Date.prototype, 'toISOString').mockImplementation(function () {
if (dateIndex < timestamps.length) {
return timestamps[dateIndex++]
}
return originalDateNow.call(this)
})
// Insert 5001 messages
for (let i = 0; i < 5001; i++) {
await service.persistExchange({
deviceId,
direction: i % 2 === 0 ? 'CPE→ACS' : 'ACS→CPE',
sessionId: `session-${i}`,
rawXml: `<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0"><soap-env:Body><cwmp:Inform><Seq>${i}</Seq></cwmp:Inform></soap-env:Body></soap-env:Envelope>`,
})
}
// Verify: 5001 documents were inserted
expect(insertedDocs).toHaveLength(5001)
// Verify: the deletion targeted the oldest message (sequenceNumber = 1)
// This means messages with seq 2..5001 remain (the 5000 most recent)
expect(deletedMaxSeq).toBe(1)
// Verify: retained messages are the most recent by checking the last inserted
// has the highest sequence number (5001) and most recent timestamp
const lastInserted = insertedDocs[insertedDocs.length - 1]
expect(lastInserted.sequenceNumber).toBe(5001)
// The retained set would be seq 2..5001 (most recent 5000)
// The deleted set would be seq 1 (oldest)
// Verify the oldest deleted doc has the earliest timestamp
const oldestDoc = insertedDocs[0]
const newestRetainedDoc = insertedDocs[1] // seq 2 is now the oldest retained
expect(new Date(oldestDoc.timestamp).getTime()).toBeLessThan(
new Date(newestRetainedDoc.timestamp).getTime()
)
vi.restoreAllMocks()
})
it('does not delete when count is exactly at cap (5000 messages)', async () => {
const deviceId = 'device-exact-cap'
countDocumentsMock.mockImplementation((_filter?: any) => {
return Promise.resolve(5000) // Exactly at cap, no deletion needed
})
await service.persistExchange({
deviceId,
direction: 'CPE→ACS',
sessionId: 'session-exact',
rawXml: '<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0"><soap-env:Body><cwmp:Inform/></soap-env:Body></soap-env:Envelope>',
})
// Verify: deleteMany was NOT called (count <= maxCap)
expect(deleteManyMock).not.toHaveBeenCalled()
})
})
+452
View File
@@ -0,0 +1,452 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
/**
* SSE Reconnection with Last-Event-ID Integration Test
*
* Validates: Requirements 6.8, 6.9
*
* This is a unit-level integration test using mocks (not a real server).
* It simulates the full reconnection lifecycle:
* 1. Connect to SSE stream, receive 5 messages
* 2. Simulate disconnect
* 3. Reconnect with Last-Event-ID set to sequence number of last received message
* 4. Verify: no duplicates received, messages resume from correct point
*/
// --- Mocks ---
const mockFindAfterSequence = vi.fn()
const mockFindByDevice = vi.fn()
const mockHasExchanges = vi.fn()
const mockGetMetadata = vi.fn()
const mockDeleteByDevice = vi.fn()
vi.mock('#models/exchange', () => ({
default: {
findByDevice: (...args: any[]) => mockFindByDevice(...args),
hasExchanges: (...args: any[]) => mockHasExchanges(...args),
getMetadata: (...args: any[]) => mockGetMetadata(...args),
deleteByDevice: (...args: any[]) => mockDeleteByDevice(...args),
findAfterSequence: (...args: any[]) => mockFindAfterSequence(...args),
},
}))
const mockIsTraced = vi.fn()
vi.mock('#services/tr069_trace_service', () => ({
Tr069TraceService: {
getInstance: () => ({
isTraced: mockIsTraced,
}),
},
}))
const mockEmitter = new EventEmitter()
const mockSubscribe = vi.fn((deviceId: string, listener: Function) => {
mockEmitter.on(`exchange:${deviceId}`, listener as any)
})
const mockUnsubscribe = vi.fn((deviceId: string, listener: Function) => {
mockEmitter.off(`exchange:${deviceId}`, listener as any)
})
vi.mock('#services/exchange_store_service', () => ({
ExchangeStoreService: {
getInstance: () => ({
subscribe: mockSubscribe,
unsubscribe: mockUnsubscribe,
}),
},
}))
vi.mock('#config/exchange_store', () => ({
default: {
maxMessagesPerDevice: 5000,
sse: {
inactivityTimeoutMs: 300_000,
absoluteTimeoutMs: 7_200_000,
maxConnectionsPerDevice: 5,
retryIntervalMs: 3000,
},
featureFlags: {
persistence: true,
sseEnabled: true,
apiSource: 'mongodb',
},
},
}))
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}))
import ExchangeController from '../../app/controllers/exchange_controller.js'
// --- Helpers ---
interface SSEEvent {
id: number
data: any
}
function createStreamContext(options: { deviceId: string; lastEventId?: string }) {
const written: string[] = []
let ended = false
const reqEmitter = new EventEmitter()
const nodeRes = {
writeHead: vi.fn(),
write: vi.fn((chunk: string) => {
written.push(chunk)
}),
end: vi.fn(() => {
ended = true
}),
}
const responseObj = {
response: nodeRes,
status: vi.fn(function (this: any, _code: number) {
return this
}),
json: vi.fn((data: any) => data),
}
const ctx = {
params: { deviceId: options.deviceId },
request: {
header: vi.fn((name: string) => {
if (name === 'Last-Event-ID') return options.lastEventId
return undefined
}),
request: reqEmitter,
id: () => 'test-correlation-id',
},
response: responseObj,
}
return {
ctx: ctx as any,
nodeRes,
reqEmitter,
getWritten: () => written,
isEnded: () => ended,
disconnect: () => reqEmitter.emit('close'),
}
}
/**
* Parse SSE events from written chunks.
* Each SSE event has format: `id: <number>\ndata: <json>\n\n`
* Ignores retry messages and empty chunks.
*/
function parseSSEEvents(written: string[]): SSEEvent[] {
const events: SSEEvent[] = []
for (const chunk of written) {
// Skip retry messages
if (chunk.startsWith('retry:')) continue
const idMatch = chunk.match(/^id: (\d+)\n/)
const dataMatch = chunk.match(/\ndata: (.+)\n\n$/)
if (idMatch && dataMatch) {
events.push({
id: Number(idMatch[1]),
data: JSON.parse(dataMatch[1]),
})
}
}
return events
}
function createExchange(deviceId: string, sequenceNumber: number) {
return {
deviceId,
direction: sequenceNumber % 2 === 1 ? 'CPE→ACS' : 'ACS→CPE',
timestamp: new Date(Date.now() + sequenceNumber * 1000).toISOString(),
sessionId: '10.0.0.1:54321->192.168.1.1:8080',
cwmpMethod: sequenceNumber % 2 === 1 ? 'Inform' : 'InformResponse',
rawXml: `<soap-env:Envelope><cwmp:${sequenceNumber % 2 === 1 ? 'Inform' : 'InformResponse'}/></soap-env:Envelope>`,
sequenceNumber,
}
}
// --- Tests ---
describe('SSE Reconnection with Last-Event-ID', () => {
let controller: ExchangeController
const DEVICE_ID = 'device-reconnect-test'
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
controller = new ExchangeController()
mockIsTraced.mockReturnValue(true)
mockFindAfterSequence.mockResolvedValue([])
})
afterEach(() => {
vi.useRealTimers()
mockEmitter.removeAllListeners()
})
it('full reconnection lifecycle: receive 5 messages, disconnect, reconnect with Last-Event-ID, no duplicates', async () => {
// --- Phase 1: Initial connection, receive 5 messages ---
const { ctx: ctx1, getWritten: getWritten1, disconnect: disconnect1 } = createStreamContext({
deviceId: DEVICE_ID,
})
await controller.stream(ctx1)
// Emit 5 exchanges through the EventEmitter (simulating real-time messages)
const allExchanges = Array.from({ length: 10 }, (_, i) => createExchange(DEVICE_ID, i + 1))
for (let i = 0; i < 5; i++) {
mockEmitter.emit(`exchange:${DEVICE_ID}`, allExchanges[i])
}
// Verify 5 messages were received
const eventsPhase1 = parseSSEEvents(getWritten1())
expect(eventsPhase1).toHaveLength(5)
expect(eventsPhase1.map((e) => e.id)).toEqual([1, 2, 3, 4, 5])
// Get the Last-Event-ID (sequence number of last received message)
const lastEventId = eventsPhase1[eventsPhase1.length - 1].id
expect(lastEventId).toBe(5)
// --- Phase 2: Simulate disconnect ---
disconnect1()
// Verify unsubscribe was called (cleanup)
expect(mockUnsubscribe).toHaveBeenCalledWith(DEVICE_ID, expect.any(Function))
// --- Phase 3: While disconnected, 5 more messages arrive (seq 6-10) ---
// These would have been persisted to MongoDB by ExchangeStoreService
const missedExchanges = allExchanges.slice(5) // seq 6-10
mockFindAfterSequence.mockResolvedValue(missedExchanges)
// --- Phase 4: Reconnect with Last-Event-ID ---
const { ctx: ctx2, getWritten: getWritten2 } = createStreamContext({
deviceId: DEVICE_ID,
lastEventId: String(lastEventId), // "5"
})
await controller.stream(ctx2)
// Verify findAfterSequence was called with the correct sequence number
expect(mockFindAfterSequence).toHaveBeenCalledWith(DEVICE_ID, 5)
// --- Phase 5: Verify no duplicates, messages resume from correct point ---
const eventsPhase2 = parseSSEEvents(getWritten2())
expect(eventsPhase2).toHaveLength(5)
expect(eventsPhase2.map((e) => e.id)).toEqual([6, 7, 8, 9, 10])
// Verify no sequence numbers from phase 1 appear in phase 2
const phase1Ids = new Set(eventsPhase1.map((e) => e.id))
const phase2Ids = eventsPhase2.map((e) => e.id)
for (const id of phase2Ids) {
expect(phase1Ids.has(id)).toBe(false)
}
// Verify all messages are accounted for (no gaps)
const allReceivedIds = [...eventsPhase1.map((e) => e.id), ...phase2Ids]
expect(allReceivedIds).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
})
it('reconnection with Last-Event-ID=0 resumes all available messages', async () => {
const exchanges = Array.from({ length: 3 }, (_, i) => createExchange(DEVICE_ID, i + 1))
mockFindAfterSequence.mockResolvedValue(exchanges)
const { ctx, getWritten } = createStreamContext({
deviceId: DEVICE_ID,
lastEventId: '0',
})
await controller.stream(ctx)
expect(mockFindAfterSequence).toHaveBeenCalledWith(DEVICE_ID, 0)
const events = parseSSEEvents(getWritten())
expect(events).toHaveLength(3)
expect(events.map((e) => e.id)).toEqual([1, 2, 3])
})
it('reconnection with Last-Event-ID pointing to latest message yields no missed messages', async () => {
mockFindAfterSequence.mockResolvedValue([])
const { ctx, getWritten } = createStreamContext({
deviceId: DEVICE_ID,
lastEventId: '100',
})
await controller.stream(ctx)
expect(mockFindAfterSequence).toHaveBeenCalledWith(DEVICE_ID, 100)
const events = parseSSEEvents(getWritten())
expect(events).toHaveLength(0)
})
it('reconnection sends missed messages then continues receiving live events', async () => {
// Missed messages during disconnect (seq 6-8)
const missedExchanges = [
createExchange(DEVICE_ID, 6),
createExchange(DEVICE_ID, 7),
createExchange(DEVICE_ID, 8),
]
mockFindAfterSequence.mockResolvedValue(missedExchanges)
const { ctx, getWritten } = createStreamContext({
deviceId: DEVICE_ID,
lastEventId: '5',
})
await controller.stream(ctx)
// New live exchange arrives after reconnection (seq 9)
const liveExchange = createExchange(DEVICE_ID, 9)
mockEmitter.emit(`exchange:${DEVICE_ID}`, liveExchange)
const events = parseSSEEvents(getWritten())
expect(events).toHaveLength(4) // 3 missed + 1 live
expect(events.map((e) => e.id)).toEqual([6, 7, 8, 9])
// Verify ordering: missed messages come before live ones
for (let i = 1; i < events.length; i++) {
expect(events[i].id).toBeGreaterThan(events[i - 1].id)
}
})
it('SSE event id field always equals sequenceNumber (Property 13 validation)', async () => {
const { ctx, getWritten } = createStreamContext({
deviceId: DEVICE_ID,
})
await controller.stream(ctx)
// Emit exchanges with various sequence numbers
const sequenceNumbers = [1, 5, 42, 100, 9999]
for (const seq of sequenceNumbers) {
const exchange = createExchange(DEVICE_ID, seq)
mockEmitter.emit(`exchange:${DEVICE_ID}`, exchange)
}
const events = parseSSEEvents(getWritten())
expect(events).toHaveLength(5)
// Each event id must match the exchange's sequenceNumber
for (const event of events) {
expect(event.id).toBe(event.data.sequenceNumber)
}
})
it('multiple rapid reconnections do not produce duplicates', async () => {
const allReceived: number[] = []
// First connection: receive messages 1-3
const { ctx: ctx1, getWritten: getWritten1, disconnect: disconnect1 } = createStreamContext({
deviceId: DEVICE_ID,
})
await controller.stream(ctx1)
for (let i = 1; i <= 3; i++) {
mockEmitter.emit(`exchange:${DEVICE_ID}`, createExchange(DEVICE_ID, i))
}
const events1 = parseSSEEvents(getWritten1())
allReceived.push(...events1.map((e) => e.id))
disconnect1()
// Second connection: reconnect from seq 3, receive missed 4-5
mockFindAfterSequence.mockResolvedValue([
createExchange(DEVICE_ID, 4),
createExchange(DEVICE_ID, 5),
])
const { ctx: ctx2, getWritten: getWritten2, disconnect: disconnect2 } = createStreamContext({
deviceId: DEVICE_ID,
lastEventId: '3',
})
await controller.stream(ctx2)
const events2 = parseSSEEvents(getWritten2())
allReceived.push(...events2.map((e) => e.id))
disconnect2()
// Third connection: reconnect from seq 5, receive missed 6-7
mockFindAfterSequence.mockResolvedValue([
createExchange(DEVICE_ID, 6),
createExchange(DEVICE_ID, 7),
])
const { ctx: ctx3, getWritten: getWritten3 } = createStreamContext({
deviceId: DEVICE_ID,
lastEventId: '5',
})
await controller.stream(ctx3)
const events3 = parseSSEEvents(getWritten3())
allReceived.push(...events3.map((e) => e.id))
// Verify no duplicates across all connections
const uniqueIds = new Set(allReceived)
expect(uniqueIds.size).toBe(allReceived.length)
// Verify continuous sequence
expect(allReceived).toEqual([1, 2, 3, 4, 5, 6, 7])
})
it('reconnection handles non-numeric Last-Event-ID gracefully (no crash)', async () => {
const { ctx, getWritten } = createStreamContext({
deviceId: DEVICE_ID,
lastEventId: 'invalid-not-a-number',
})
// Should not throw
await controller.stream(ctx)
// findAfterSequence should NOT be called with NaN
expect(mockFindAfterSequence).not.toHaveBeenCalled()
// Connection should still be established (live events still work)
mockEmitter.emit(`exchange:${DEVICE_ID}`, createExchange(DEVICE_ID, 1))
const events = parseSSEEvents(getWritten())
expect(events).toHaveLength(1)
expect(events[0].id).toBe(1)
})
it('data integrity: reconnected messages have the same content as originally persisted', async () => {
const originalExchanges = Array.from({ length: 5 }, (_, i) => createExchange(DEVICE_ID, i + 1))
mockFindAfterSequence.mockResolvedValue(originalExchanges)
const { ctx, getWritten } = createStreamContext({
deviceId: DEVICE_ID,
lastEventId: '0',
})
await controller.stream(ctx)
const events = parseSSEEvents(getWritten())
// Verify data integrity for each reconnected message
for (let i = 0; i < events.length; i++) {
const event = events[i]
const original = originalExchanges[i]
expect(event.data.deviceId).toBe(original.deviceId)
expect(event.data.direction).toBe(original.direction)
expect(event.data.timestamp).toBe(original.timestamp)
expect(event.data.sessionId).toBe(original.sessionId)
expect(event.data.cwmpMethod).toBe(original.cwmpMethod)
expect(event.data.rawXml).toBe(original.rawXml)
expect(event.data.sequenceNumber).toBe(original.sequenceNumber)
}
})
})
+124
View File
@@ -0,0 +1,124 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
import { MongoClient, Db } from 'mongodb'
const MONGO_URI = 'mongodb://adam:adam@localhost:27017/adam-acs-test?authSource=admin'
const COLLECTION_NAME = 'cwmp_exchanges'
let client: MongoClient
let db: Db
describe('TTL expiration integration test', () => {
beforeAll(async () => {
client = new MongoClient(MONGO_URI)
await client.connect()
db = client.db('adam-acs-test')
// Create the TTL index (as the migration would)
const collection = db.collection(COLLECTION_NAME)
await collection.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: 86400, name: 'idx_createdAt_ttl', background: true }
)
})
afterAll(async () => {
await db.dropDatabase()
await client.close()
})
beforeEach(async () => {
await db.collection(COLLECTION_NAME).deleteMany({})
})
/**
* Validates: Requirements 7.3
*
* Verify the TTL index is configured with expireAfterSeconds = 86400 (24 hours).
* In a real MongoDB deployment, the TTL monitor thread runs every 60s and removes
* documents whose createdAt field is older than 86400 seconds. For this test, we
* verify the index configuration is correct rather than waiting for the TTL monitor.
*/
it('should have a TTL index on createdAt with expireAfterSeconds = 86400', async () => {
const collection = db.collection(COLLECTION_NAME)
const indexes = await collection.indexes()
const ttlIndex = indexes.find((idx) => idx.name === 'idx_createdAt_ttl')
expect(ttlIndex).toBeDefined()
expect(ttlIndex!.key).toEqual({ createdAt: 1 })
expect(ttlIndex!.expireAfterSeconds).toBe(86400)
})
it('should store exchanges with createdAt set to 25 hours ago as expired candidates', async () => {
const collection = db.collection(COLLECTION_NAME)
const twentyFiveHoursAgo = new Date(Date.now() - 25 * 60 * 60 * 1000)
// Insert exchanges with createdAt set 25 hours in the past
const expiredExchanges = Array.from({ length: 5 }, (_, i) => ({
deviceId: 'device-ttl-test',
direction: 'CPE→ACS' as const,
timestamp: new Date(twentyFiveHoursAgo.getTime() + i * 1000).toISOString(),
sessionId: '10.0.0.1:54321->192.168.1.1:8080',
cwmpMethod: 'Inform',
rawXml: `<soap-env:Envelope><soap-env:Body><cwmp:Inform/></soap-env:Body></soap-env:Envelope>`,
sequenceNumber: i + 1,
createdAt: twentyFiveHoursAgo,
}))
await collection.insertMany(expiredExchanges)
// Verify the documents were inserted
const count = await collection.countDocuments({ deviceId: 'device-ttl-test' })
expect(count).toBe(5)
// Verify all documents have a createdAt older than 24 hours
const docs = await collection.find({ deviceId: 'device-ttl-test' }).toArray()
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
for (const doc of docs) {
expect(doc.createdAt).toBeInstanceOf(Date)
expect(doc.createdAt.getTime()).toBeLessThan(twentyFourHoursAgo.getTime())
}
})
it('should NOT mark recent exchanges as expired candidates', async () => {
const collection = db.collection(COLLECTION_NAME)
const now = new Date()
// Insert exchanges with current createdAt (should NOT be expired)
const recentExchanges = Array.from({ length: 3 }, (_, i) => ({
deviceId: 'device-ttl-recent',
direction: 'ACS→CPE' as const,
timestamp: new Date(now.getTime() + i * 1000).toISOString(),
sessionId: '10.0.0.1:54321->192.168.1.1:8080',
cwmpMethod: 'GetParameterValuesResponse',
rawXml: `<soap-env:Envelope><soap-env:Body><cwmp:GetParameterValuesResponse/></soap-env:Body></soap-env:Envelope>`,
sequenceNumber: i + 1,
createdAt: now,
}))
await collection.insertMany(recentExchanges)
// Verify documents exist and their createdAt is within 24 hours
const docs = await collection.find({ deviceId: 'device-ttl-recent' }).toArray()
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
expect(docs).toHaveLength(3)
for (const doc of docs) {
expect(doc.createdAt).toBeInstanceOf(Date)
expect(doc.createdAt.getTime()).toBeGreaterThan(twentyFourHoursAgo.getTime())
}
})
it('TTL index expireAfterSeconds should equal exactly 24 hours in seconds', async () => {
const collection = db.collection(COLLECTION_NAME)
const indexes = await collection.indexes()
const ttlIndex = indexes.find((idx) => idx.name === 'idx_createdAt_ttl')
expect(ttlIndex).toBeDefined()
// 24 hours = 24 * 60 * 60 = 86400 seconds
const twentyFourHoursInSeconds = 24 * 60 * 60
expect(ttlIndex!.expireAfterSeconds).toBe(twentyFourHoursInSeconds)
})
})
@@ -0,0 +1,370 @@
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 }
)
})
})
})
@@ -0,0 +1,599 @@
import { describe, it, expect } from 'vitest'
import fc from 'fast-check'
import {
formatTimestamp,
getArrowClass,
truncateMethod,
needsTruncation,
} from '../../../inertia/helpers/sequence_viewer_helpers.js'
/**
* Property 9: Arrow Direction Correctness
* Validates: Requirements 4.2
*
* For any list of CWMP messages with mixed directions, the SequenceViewer SHALL render
* each message arrow pointing left-to-right for "CPE→ACS" and right-to-left for "ACS→CPE",
* with the overall list ordered by timestamp ascending.
*
* Since this is a component data logic test, we test the pure logic:
* - Direction → CSS class mapping
* - Timestamp ordering of the messages array
*/
const PBT_NUM_RUNS = 100
// --- Types (matching SequenceViewer.vue CwmpExchange interface) ---
interface CwmpExchange {
sequenceNumber: number
timestamp: string
direction: 'CPE→ACS' | 'ACS→CPE'
cwmpMethod: string
sessionId: string
rawXml: string
}
// --- Pure logic functions ---
/**
* Checks if an array of messages is ordered by timestamp ascending.
* The SequenceViewer displays messages in array order, and the API returns them
* ordered by timestamp ascending.
*/
function isTimestampAscending(messages: CwmpExchange[]): boolean {
for (let i = 1; i < messages.length; i++) {
if (messages[i].timestamp < messages[i - 1].timestamp) {
return false
}
}
return true
}
// --- Arbitraries ---
/**
* Generates a random direction value.
*/
const arbDirection = fc.constantFrom<'CPE→ACS' | 'ACS→CPE'>('CPE→ACS', 'ACS→CPE')
/**
* Generates a random ISO 8601 timestamp within a reasonable range.
*/
const arbTimestamp = fc
.integer({
min: new Date('2024-01-01T00:00:00.000Z').getTime(),
max: new Date('2025-12-31T23:59:59.999Z').getTime(),
})
.map((ms) => new Date(ms).toISOString())
/**
* Generates a random CWMP method name.
*/
const arbCwmpMethod = fc.constantFrom(
'Inform',
'GetParameterValues',
'GetParameterValuesResponse',
'SetParameterValues',
'SetParameterValuesResponse',
'AddObject',
'AddObjectResponse',
'DeleteObject',
'DeleteObjectResponse',
'Download',
'DownloadResponse',
'Reboot',
'RebootResponse',
'FactoryReset',
'Fault',
'Empty',
'Unknown'
)
/**
* Generates a single CwmpExchange with the given sequence number and a sorted-friendly timestamp.
*/
function arbExchange(seqNum: number): fc.Arbitrary<CwmpExchange> {
return fc
.record({
timestamp: arbTimestamp,
direction: arbDirection,
cwmpMethod: arbCwmpMethod,
sessionId: fc.string({ minLength: 5, maxLength: 30 }),
rawXml: fc.string({ minLength: 0, maxLength: 100 }),
})
.map(({ timestamp, direction, cwmpMethod, sessionId, rawXml }) => ({
sequenceNumber: seqNum,
timestamp,
direction,
cwmpMethod,
sessionId,
rawXml,
}))
}
/**
* Generates a list of CwmpExchange messages sorted by timestamp ascending
* (simulating what the API returns and how SequenceViewer receives them).
*/
const arbSortedExchangeList = fc
.array(
fc.record({
timestamp: arbTimestamp,
direction: arbDirection,
cwmpMethod: arbCwmpMethod,
sessionId: fc.string({ minLength: 5, maxLength: 30 }),
rawXml: fc.string({ minLength: 0, maxLength: 100 }),
}),
{ minLength: 1, maxLength: 50 }
)
.map((items) => {
// Sort by timestamp ascending to simulate API-ordered data
items.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
return items.map((item, index) => ({
...item,
sequenceNumber: index + 1,
}))
})
/**
* Generates a list with ALL messages having the same direction.
*/
function arbUniformDirectionList(direction: 'CPE→ACS' | 'ACS→CPE') {
return fc
.array(
fc.record({
timestamp: arbTimestamp,
cwmpMethod: arbCwmpMethod,
sessionId: fc.string({ minLength: 5, maxLength: 30 }),
rawXml: fc.string({ minLength: 0, maxLength: 100 }),
}),
{ minLength: 1, maxLength: 30 }
)
.map((items) => {
items.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
return items.map((item, index) => ({
...item,
direction,
sequenceNumber: index + 1,
}))
})
}
// --- Property Tests ---
describe('Property 9: Arrow Direction Correctness', () => {
it('CPE→ACS messages always get "arrow-right" class (left-to-right arrow)', () => {
fc.assert(
fc.property(arbSortedExchangeList, (messages) => {
for (const message of messages) {
if (message.direction === 'CPE→ACS') {
expect(getArrowClass(message.direction)).toBe('arrow-right')
}
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('ACS→CPE messages always get "arrow-left" class (right-to-left arrow)', () => {
fc.assert(
fc.property(arbSortedExchangeList, (messages) => {
for (const message of messages) {
if (message.direction === 'ACS→CPE') {
expect(getArrowClass(message.direction)).toBe('arrow-left')
}
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('arrow class is exclusively determined by direction — no other field affects it', () => {
fc.assert(
fc.property(arbSortedExchangeList, (messages) => {
for (const message of messages) {
const arrowClass = getArrowClass(message.direction)
// The arrow class is a bijection: exactly two valid outputs
expect(['arrow-right', 'arrow-left']).toContain(arrowClass)
// And each direction maps to exactly one class
if (message.direction === 'CPE→ACS') {
expect(arrowClass).toBe('arrow-right')
} else {
expect(arrowClass).toBe('arrow-left')
}
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('messages with mixed directions are displayed in timestamp-ascending order', () => {
fc.assert(
fc.property(arbSortedExchangeList, (messages) => {
// The API-sorted list must be in timestamp ascending order
expect(isTimestampAscending(messages)).toBe(true)
// Each message still gets the correct arrow class regardless of position
for (const message of messages) {
const expectedClass =
message.direction === 'CPE→ACS' ? 'arrow-right' : 'arrow-left'
expect(getArrowClass(message.direction)).toBe(expectedClass)
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('all-CPE→ACS lists: every arrow is "arrow-right" and order is maintained', () => {
fc.assert(
fc.property(arbUniformDirectionList('CPE→ACS'), (messages) => {
expect(isTimestampAscending(messages)).toBe(true)
for (const message of messages) {
expect(getArrowClass(message.direction)).toBe('arrow-right')
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('all-ACS→CPE lists: every arrow is "arrow-left" and order is maintained', () => {
fc.assert(
fc.property(arbUniformDirectionList('ACS→CPE'), (messages) => {
expect(isTimestampAscending(messages)).toBe(true)
for (const message of messages) {
expect(getArrowClass(message.direction)).toBe('arrow-left')
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('single message: correct arrow class regardless of direction', () => {
fc.assert(
fc.property(arbDirection, arbTimestamp, (direction, timestamp) => {
const message: CwmpExchange = {
sequenceNumber: 1,
timestamp,
direction,
cwmpMethod: 'Inform',
sessionId: 'test-session',
rawXml: '<xml/>',
}
const expectedClass =
direction === 'CPE→ACS' ? 'arrow-right' : 'arrow-left'
expect(getArrowClass(message.direction)).toBe(expectedClass)
// Single-element list is trivially sorted
expect(isTimestampAscending([message])).toBe(true)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('alternating directions preserve timestamp order and correct arrow assignment', () => {
// Generate an alternating direction list
const arbAlternatingList = fc
.array(arbTimestamp, { minLength: 2, maxLength: 40 })
.map((timestamps) => {
const sorted = [...timestamps].sort()
return sorted.map((ts, i) => ({
sequenceNumber: i + 1,
timestamp: ts,
direction: (i % 2 === 0 ? 'CPE→ACS' : 'ACS→CPE') as 'CPE→ACS' | 'ACS→CPE',
cwmpMethod: 'Inform',
sessionId: 'session-alt',
rawXml: '',
}))
})
fc.assert(
fc.property(arbAlternatingList, (messages) => {
expect(isTimestampAscending(messages)).toBe(true)
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (i % 2 === 0) {
expect(msg.direction).toBe('CPE→ACS')
expect(getArrowClass(msg.direction)).toBe('arrow-right')
} else {
expect(msg.direction).toBe('ACS→CPE')
expect(getArrowClass(msg.direction)).toBe('arrow-left')
}
}
}),
{ numRuns: PBT_NUM_RUNS }
)
})
})
/**
* Property 10: Method Label Truncation
* Validates: Requirements 4.3
*
* For any cwmpMethod name, if its length exceeds 40 characters, the displayed label
* SHALL be truncated to 40 characters; if length ≤ 40, the full name SHALL be displayed.
*/
describe('Property 10: Method Label Truncation', () => {
it('strings longer than 40 characters are truncated to exactly 40 chars plus ellipsis', () => {
fc.assert(
fc.property(
fc.string({ minLength: 41, maxLength: 200 }),
(method) => {
const result = truncateMethod(method)
// The visible portion (before ellipsis) must be exactly the first 40 chars of the original
expect(result.substring(0, 40)).toBe(method.substring(0, 40))
// The result ends with the ellipsis character
expect(result.endsWith('…')).toBe(true)
// Total length is 41 (40 chars + 1 ellipsis char '…')
expect(result.length).toBe(41)
}
),
{ numRuns: PBT_NUM_RUNS }
)
})
it('strings of 40 characters or fewer are displayed in full without truncation', () => {
fc.assert(
fc.property(
fc.string({ minLength: 0, maxLength: 40 }),
(method) => {
const result = truncateMethod(method)
// The full original string is returned unchanged
expect(result).toBe(method)
expect(result.length).toBe(method.length)
}
),
{ numRuns: PBT_NUM_RUNS }
)
})
it('needsTruncation returns true if and only if method length exceeds 40', () => {
fc.assert(
fc.property(
fc.string({ minLength: 0, maxLength: 200 }),
(method) => {
const result = needsTruncation(method)
expect(result).toBe(method.length > 40)
}
),
{ numRuns: PBT_NUM_RUNS }
)
})
it('truncation preserves the first 40 characters of the original string', () => {
fc.assert(
fc.property(
fc.string({ minLength: 41, maxLength: 300 }),
(method) => {
const result = truncateMethod(method)
// The truncated output starts with the first 40 chars of the input
expect(result.substring(0, 40)).toBe(method.substring(0, 40))
}
),
{ numRuns: PBT_NUM_RUNS }
)
})
it('exactly 40 characters are NOT truncated', () => {
fc.assert(
fc.property(
fc.string({ minLength: 40, maxLength: 40 }),
(method) => {
const result = truncateMethod(method)
expect(result).toBe(method)
expect(result.length).toBe(40)
expect(needsTruncation(method)).toBe(false)
}
),
{ numRuns: PBT_NUM_RUNS }
)
})
it('exactly 41 characters ARE truncated', () => {
fc.assert(
fc.property(
fc.string({ minLength: 41, maxLength: 41 }),
(method) => {
const result = truncateMethod(method)
expect(result.length).toBe(41)
expect(result.endsWith('…')).toBe(true)
expect(needsTruncation(method)).toBe(true)
// The first 40 chars match
expect(result.substring(0, 40)).toBe(method.substring(0, 40))
}
),
{ numRuns: PBT_NUM_RUNS }
)
})
it('empty string is not truncated', () => {
const result = truncateMethod('')
expect(result).toBe('')
expect(needsTruncation('')).toBe(false)
})
it('unicode characters in method names respect character-length truncation', () => {
// Generate strings with multi-byte unicode characters (BMP range)
const unicodeMethodArb = fc
.array(fc.integer({ min: 0x0100, max: 0xFFFF }), { minLength: 41, maxLength: 100 })
.map((codePoints) => String.fromCharCode(...codePoints))
fc.assert(
fc.property(unicodeMethodArb, (method) => {
const result = truncateMethod(method)
// Truncation is based on JS string length (char count), not byte count
expect(result.length).toBe(41)
expect(result.endsWith('…')).toBe(true)
expect(needsTruncation(method)).toBe(true)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
})
/**
* Property 11: Timestamp Formatting
* Validates: Requirements 4.4
*
* For any valid ISO 8601 timestamp, the formatted display SHALL match the pattern
* `HH:mm:ss.SSS` (hours:minutes:seconds.milliseconds).
*/
describe('Property 11: Timestamp Formatting', () => {
// ─── Arbitraries ─────────────────────────────────────────────────────────────
/**
* Arbitrary that generates valid ISO 8601 timestamps as strings.
* Uses integer milliseconds to avoid invalid Date issues.
*/
const validIsoTimestamp = fc
.integer({
min: 0, // 1970-01-01T00:00:00.000Z
max: 4102444799999, // 2099-12-31T23:59:59.999Z
})
.map((ms) => new Date(ms).toISOString())
/**
* Arbitrary that generates ISO 8601 timestamps with timezone offsets.
* E.g., "2025-06-15T14:30:00.123+05:30"
*/
const isoTimestampWithOffset = fc
.record({
ms: fc.integer({ min: 0, max: 4102444799999 }),
offsetHours: fc.integer({ min: -12, max: 14 }),
offsetMinutes: fc.constantFrom(0, 30, 45),
})
.map(({ ms, offsetHours, offsetMinutes }) => {
const date = new Date(ms)
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const day = String(date.getUTCDate()).padStart(2, '0')
const hours = String(date.getUTCHours()).padStart(2, '0')
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
const seconds = String(date.getUTCSeconds()).padStart(2, '0')
const millis = String(date.getUTCMilliseconds()).padStart(3, '0')
const sign = offsetHours >= 0 ? '+' : '-'
const absHours = String(Math.abs(offsetHours)).padStart(2, '0')
const absMinutes = String(offsetMinutes).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${millis}${sign}${absHours}:${absMinutes}`
})
// ─── Pattern regex ───────────────────────────────────────────────────────────
const TIMESTAMP_PATTERN = /^\d{2}:\d{2}:\d{2}\.\d{3}$/
// ─── Property Tests ──────────────────────────────────────────────────────────
it('should format any valid ISO 8601 timestamp to HH:mm:ss.SSS pattern', () => {
fc.assert(
fc.property(validIsoTimestamp, (isoStr) => {
const result = formatTimestamp(isoStr)
expect(result).toMatch(TIMESTAMP_PATTERN)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('should produce correct UTC time components from ISO timestamp', () => {
fc.assert(
fc.property(validIsoTimestamp, (isoStr) => {
const result = formatTimestamp(isoStr)
const date = new Date(isoStr)
const expectedHours = String(date.getUTCHours()).padStart(2, '0')
const expectedMinutes = String(date.getUTCMinutes()).padStart(2, '0')
const expectedSeconds = String(date.getUTCSeconds()).padStart(2, '0')
const expectedMillis = String(date.getUTCMilliseconds()).padStart(3, '0')
const expected = `${expectedHours}:${expectedMinutes}:${expectedSeconds}.${expectedMillis}`
expect(result).toBe(expected)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('should format timestamps with timezone offsets to HH:mm:ss.SSS pattern', () => {
fc.assert(
fc.property(isoTimestampWithOffset, (isoStr) => {
const result = formatTimestamp(isoStr)
expect(result).toMatch(TIMESTAMP_PATTERN)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('should produce valid hour values (00-23)', () => {
fc.assert(
fc.property(validIsoTimestamp, (isoStr) => {
const result = formatTimestamp(isoStr)
const hours = parseInt(result.substring(0, 2), 10)
expect(hours).toBeGreaterThanOrEqual(0)
expect(hours).toBeLessThanOrEqual(23)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('should produce valid minute values (00-59)', () => {
fc.assert(
fc.property(validIsoTimestamp, (isoStr) => {
const result = formatTimestamp(isoStr)
const minutes = parseInt(result.substring(3, 5), 10)
expect(minutes).toBeGreaterThanOrEqual(0)
expect(minutes).toBeLessThanOrEqual(59)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('should produce valid second values (00-59)', () => {
fc.assert(
fc.property(validIsoTimestamp, (isoStr) => {
const result = formatTimestamp(isoStr)
const seconds = parseInt(result.substring(6, 8), 10)
expect(seconds).toBeGreaterThanOrEqual(0)
expect(seconds).toBeLessThanOrEqual(59)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
it('should produce valid millisecond values (000-999)', () => {
fc.assert(
fc.property(validIsoTimestamp, (isoStr) => {
const result = formatTimestamp(isoStr)
const millis = parseInt(result.substring(9, 12), 10)
expect(millis).toBeGreaterThanOrEqual(0)
expect(millis).toBeLessThanOrEqual(999)
}),
{ numRuns: PBT_NUM_RUNS }
)
})
// ─── Edge cases ──────────────────────────────────────────────────────────────
it('should format midnight correctly', () => {
const result = formatTimestamp('2025-01-01T00:00:00.000Z')
expect(result).toBe('00:00:00.000')
})
it('should format noon correctly', () => {
const result = formatTimestamp('2025-01-01T12:00:00.000Z')
expect(result).toBe('12:00:00.000')
})
it('should format end-of-day correctly', () => {
const result = formatTimestamp('2025-01-01T23:59:59.999Z')
expect(result).toBe('23:59:59.999')
})
it('should handle millisecond precision correctly', () => {
const result = formatTimestamp('2025-06-15T10:30:00.001Z')
expect(result).toBe('10:30:00.001')
})
it('should handle zero milliseconds correctly', () => {
const result = formatTimestamp('2025-06-15T10:30:45.000Z')
expect(result).toBe('10:30:45.000')
})
})
@@ -0,0 +1,201 @@
import { describe, it, expect } from 'vitest'
/**
* Unit tests for the virtual scrolling logic in SequenceViewer.vue.
* Tests the pure computation functions extracted from the component's computed properties.
*
* Virtual scrolling parameters:
* - ITEM_HEIGHT = 60px
* - BUFFER_SIZE = 5 items
* - VIRTUAL_SCROLL_THRESHOLD = 200 messages
*/
const ITEM_HEIGHT = 60
const BUFFER_SIZE = 5
const VIRTUAL_SCROLL_THRESHOLD = 200
// --- Pure logic functions (mirror of component computed properties) ---
function useVirtualScroll(messageCount: number): boolean {
return messageCount > VIRTUAL_SCROLL_THRESHOLD
}
function totalHeight(messageCount: number): number {
return messageCount * ITEM_HEIGHT
}
function visibleStart(scrollTop: number, messageCount: number): number {
if (!useVirtualScroll(messageCount)) return 0
const start = Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER_SIZE
return Math.max(0, start)
}
function visibleEnd(
scrollTop: number,
containerHeight: number,
messageCount: number
): number {
if (!useVirtualScroll(messageCount)) return messageCount
const visibleCount = Math.ceil(containerHeight / ITEM_HEIGHT)
const end = Math.floor(scrollTop / ITEM_HEIGHT) + visibleCount + BUFFER_SIZE
return Math.min(messageCount, end)
}
function offsetY(scrollTop: number, messageCount: number): number {
return visibleStart(scrollTop, messageCount) * ITEM_HEIGHT
}
describe('Virtual Scrolling Logic', () => {
describe('useVirtualScroll threshold', () => {
it('should NOT use virtual scroll for 200 messages or fewer', () => {
expect(useVirtualScroll(0)).toBe(false)
expect(useVirtualScroll(100)).toBe(false)
expect(useVirtualScroll(200)).toBe(false)
})
it('should use virtual scroll for more than 200 messages', () => {
expect(useVirtualScroll(201)).toBe(true)
expect(useVirtualScroll(500)).toBe(true)
expect(useVirtualScroll(5000)).toBe(true)
})
})
describe('totalHeight', () => {
it('should compute total height as messageCount * ITEM_HEIGHT', () => {
expect(totalHeight(0)).toBe(0)
expect(totalHeight(1)).toBe(60)
expect(totalHeight(100)).toBe(6000)
expect(totalHeight(1000)).toBe(60000)
expect(totalHeight(5000)).toBe(300000)
})
})
describe('visibleStart', () => {
it('should return 0 when virtual scroll is disabled (≤200 messages)', () => {
expect(visibleStart(0, 100)).toBe(0)
expect(visibleStart(1000, 200)).toBe(0)
})
it('should return 0 when scrolled to top with virtual scroll enabled', () => {
expect(visibleStart(0, 500)).toBe(0)
})
it('should include buffer items before the visible area', () => {
// scrollTop = 600 means first visible item is at index 10
// With buffer of 5, visibleStart = 10 - 5 = 5
expect(visibleStart(600, 500)).toBe(5)
})
it('should clamp to 0 when buffer would go negative', () => {
// scrollTop = 120 means first visible item is at index 2
// With buffer of 5, visibleStart = 2 - 5 = -3 → clamped to 0
expect(visibleStart(120, 500)).toBe(0)
})
it('should compute correctly for large scroll positions', () => {
// scrollTop = 30000 means first visible item is at index 500
// With buffer of 5, visibleStart = 500 - 5 = 495
expect(visibleStart(30000, 1000)).toBe(495)
})
})
describe('visibleEnd', () => {
it('should return messageCount when virtual scroll is disabled', () => {
expect(visibleEnd(0, 500, 100)).toBe(100)
expect(visibleEnd(0, 500, 200)).toBe(200)
})
it('should compute visible end based on container height and buffer', () => {
// containerHeight = 500, so visibleCount = ceil(500/60) = 9
// scrollTop = 0, so first visible = 0
// end = 0 + 9 + 5 = 14
expect(visibleEnd(0, 500, 500)).toBe(14)
})
it('should clamp to messageCount when end exceeds total', () => {
// With few messages relative to viewport
// scrollTop = 0, containerHeight = 500, visibleCount = 9
// end = 0 + 9 + 5 = 14, but messageCount = 201
// For scrollTop near the end:
// scrollTop = 12000 → first visible = 200, end = 200 + 9 + 5 = 214 → clamped to 201
expect(visibleEnd(12000, 500, 201)).toBe(201)
})
it('should compute correctly for mid-scroll positions', () => {
// scrollTop = 3000, containerHeight = 500
// first visible = floor(3000/60) = 50
// visibleCount = ceil(500/60) = 9
// end = 50 + 9 + 5 = 64
expect(visibleEnd(3000, 500, 1000)).toBe(64)
})
})
describe('offsetY', () => {
it('should return 0 when virtual scroll is disabled', () => {
expect(offsetY(0, 100)).toBe(0)
expect(offsetY(5000, 200)).toBe(0)
})
it('should return 0 when scrolled to top', () => {
expect(offsetY(0, 500)).toBe(0)
})
it('should compute offset based on visibleStart', () => {
// visibleStart(600, 500) = 5, so offsetY = 5 * 60 = 300
expect(offsetY(600, 500)).toBe(300)
})
it('should compute correctly for large scroll positions', () => {
// visibleStart(30000, 1000) = 495, so offsetY = 495 * 60 = 29700
expect(offsetY(30000, 1000)).toBe(29700)
})
})
describe('rendered item count', () => {
it('should render only a subset of items when virtual scrolling is active', () => {
const messageCount = 1000
const containerH = 500
// For any scroll position, rendered items = visibleEnd - visibleStart
const scrollPos = 3000
const start = visibleStart(scrollPos, messageCount)
const end = visibleEnd(scrollPos, containerH, messageCount)
const renderedCount = end - start
// visibleCount = ceil(500/60) = 9, plus 2*buffer = 10 → max ~19 items rendered
// Way less than the 1000 total messages
expect(renderedCount).toBeLessThan(30)
expect(renderedCount).toBeGreaterThan(0)
})
it('should render all items when below threshold', () => {
const messageCount = 150
const containerH = 500
const scrollPos = 0
const start = visibleStart(scrollPos, messageCount)
const end = visibleEnd(scrollPos, containerH, messageCount)
expect(start).toBe(0)
expect(end).toBe(messageCount)
})
it('should maintain render performance target (≤100ms render budget)', () => {
// With 5000 messages, virtual scroll renders only ~19 items max
// At 60px height, a 500px container shows ~8 items + 2*5 buffer = 18 items
// 18 items at worst case should render well under 100ms
const messageCount = 5000
const containerH = 500
for (let scrollPos = 0; scrollPos < totalHeight(messageCount); scrollPos += 6000) {
const start = visibleStart(scrollPos, messageCount)
const end = visibleEnd(scrollPos, containerH, messageCount)
const renderedCount = end - start
// Never render more than visibleCount + 2*buffer items
const maxRendered = Math.ceil(containerH / ITEM_HEIGHT) + 2 * BUFFER_SIZE
expect(renderedCount).toBeLessThanOrEqual(maxRendered)
}
})
})
})
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
/**
* Unit tests for useExchangeStream memory management (max 1000 cached messages).
* Validates: Requirement 7.1 - client-side message cap enforcement
*
* We test the appendMessage logic directly by extracting it into a testable pattern
* since the composable relies on Vue reactivity and EventSource which require mocking.
*/
const MAX_CACHED_MESSAGES = 1000
interface CwmpExchange {
deviceId: string
direction: 'CPE→ACS' | 'ACS→CPE'
timestamp: string
sessionId: string
cwmpMethod: string
rawXml: string
sequenceNumber: number
createdAt?: string
}
/**
* Simulates the appendMessage logic from useExchangeStream.ts
* This is the exact same logic used in the composable.
*/
function appendMessage(messages: CwmpExchange[], exchange: CwmpExchange): CwmpExchange[] {
let result = [...messages, exchange]
if (result.length > MAX_CACHED_MESSAGES) {
result = result.slice(-MAX_CACHED_MESSAGES)
}
return result
}
function createExchange(sequenceNumber: number): CwmpExchange {
return {
deviceId: 'test-device',
direction: 'CPE→ACS',
timestamp: new Date(Date.now() + sequenceNumber * 1000).toISOString(),
sessionId: 'session-1',
cwmpMethod: 'Inform',
rawXml: `<soap:Envelope><soap:Body><cwmp:Inform/></soap:Body></soap:Envelope>`,
sequenceNumber,
}
}
describe('useExchangeStream memory management', () => {
describe('appendMessage cap enforcement', () => {
it('should allow messages up to MAX_CACHED_MESSAGES without discarding', () => {
let messages: CwmpExchange[] = []
for (let i = 1; i <= MAX_CACHED_MESSAGES; i++) {
messages = appendMessage(messages, createExchange(i))
}
expect(messages.length).toBe(MAX_CACHED_MESSAGES)
expect(messages[0].sequenceNumber).toBe(1)
expect(messages[messages.length - 1].sequenceNumber).toBe(MAX_CACHED_MESSAGES)
})
it('should discard oldest message when exceeding the cap', () => {
let messages: CwmpExchange[] = []
// Fill to cap
for (let i = 1; i <= MAX_CACHED_MESSAGES; i++) {
messages = appendMessage(messages, createExchange(i))
}
// Add one more (1001)
messages = appendMessage(messages, createExchange(MAX_CACHED_MESSAGES + 1))
expect(messages.length).toBe(MAX_CACHED_MESSAGES)
// Oldest (seq 1) should be discarded, newest starts at seq 2
expect(messages[0].sequenceNumber).toBe(2)
expect(messages[messages.length - 1].sequenceNumber).toBe(MAX_CACHED_MESSAGES + 1)
})
it('should always keep the most recent messages when adding many past the cap', () => {
let messages: CwmpExchange[] = []
// Add 1500 messages (500 over the cap)
const totalMessages = 1500
for (let i = 1; i <= totalMessages; i++) {
messages = appendMessage(messages, createExchange(i))
}
expect(messages.length).toBe(MAX_CACHED_MESSAGES)
// Should keep messages 501 through 1500
expect(messages[0].sequenceNumber).toBe(totalMessages - MAX_CACHED_MESSAGES + 1)
expect(messages[messages.length - 1].sequenceNumber).toBe(totalMessages)
})
it('should handle empty initial state', () => {
let messages: CwmpExchange[] = []
messages = appendMessage(messages, createExchange(1))
expect(messages.length).toBe(1)
expect(messages[0].sequenceNumber).toBe(1)
})
it('should never exceed MAX_CACHED_MESSAGES regardless of input pattern', () => {
let messages: CwmpExchange[] = []
// Rapid burst of 2000 messages
for (let i = 1; i <= 2000; i++) {
messages = appendMessage(messages, createExchange(i))
expect(messages.length).toBeLessThanOrEqual(MAX_CACHED_MESSAGES)
}
expect(messages.length).toBe(MAX_CACHED_MESSAGES)
})
it('should preserve chronological order after cap enforcement', () => {
let messages: CwmpExchange[] = []
for (let i = 1; i <= 1200; i++) {
messages = appendMessage(messages, createExchange(i))
}
// Verify all remaining messages are in ascending order
for (let i = 1; i < messages.length; i++) {
expect(messages[i].sequenceNumber).toBeGreaterThan(messages[i - 1].sequenceNumber)
expect(messages[i].timestamp > messages[i - 1].timestamp).toBe(true)
}
})
it('should correctly handle cap at exactly MAX_CACHED_MESSAGES boundary', () => {
let messages: CwmpExchange[] = []
// Add exactly MAX_CACHED_MESSAGES messages
for (let i = 1; i <= MAX_CACHED_MESSAGES; i++) {
messages = appendMessage(messages, createExchange(i))
}
expect(messages.length).toBe(MAX_CACHED_MESSAGES)
expect(messages[0].sequenceNumber).toBe(1)
// Add one more to go over by 1
messages = appendMessage(messages, createExchange(MAX_CACHED_MESSAGES + 1))
expect(messages.length).toBe(MAX_CACHED_MESSAGES)
expect(messages[0].sequenceNumber).toBe(2)
})
})
})
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,388 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// --- Mocks ---
const mockFindByDevice = vi.fn()
const mockHasExchanges = vi.fn()
const mockGetMetadata = vi.fn()
const mockDeleteByDevice = vi.fn()
vi.mock('#models/exchange', () => ({
default: {
findByDevice: (...args: any[]) => mockFindByDevice(...args),
hasExchanges: (...args: any[]) => mockHasExchanges(...args),
getMetadata: (...args: any[]) => mockGetMetadata(...args),
deleteByDevice: (...args: any[]) => mockDeleteByDevice(...args),
},
}))
const mockIsTraced = vi.fn()
const mockList = vi.fn()
vi.mock('#services/tr069_trace_service', () => ({
Tr069TraceService: {
getInstance: () => ({
isTraced: mockIsTraced,
list: mockList,
}),
},
}))
vi.mock('#services/exchange_store_service', () => ({
ExchangeStoreService: {
getInstance: () => ({
subscribe: vi.fn(),
unsubscribe: vi.fn(),
}),
},
}))
vi.mock('#config/exchange_store', () => ({
default: {
maxMessagesPerDevice: 5000,
readTimeoutMs: 5000,
sse: {
inactivityTimeoutMs: 300_000,
absoluteTimeoutMs: 7_200_000,
maxConnectionsPerDevice: 5,
retryIntervalMs: 3000,
},
featureFlags: {
persistence: true,
sseEnabled: true,
apiSource: 'mongodb',
},
},
}))
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}))
import ExchangeController from '../../../app/controllers/exchange_controller.js'
// --- Helpers ---
function createHttpContext(overrides: {
params?: Record<string, string>
qs?: Record<string, any>
} = {}) {
const responseData: { status?: number; body?: any } = {}
const response = {
status(code: number) {
responseData.status = code
return this
},
json(data: any) {
responseData.body = data
return { status: responseData.status, body: data }
},
send(data: any) {
responseData.body = data
return { status: responseData.status, body: data }
},
}
const request = {
qs: () => overrides.qs || {},
id: () => 'test-correlation-id',
}
return {
params: overrides.params || {},
request,
response,
responseData,
} as any
}
describe('ExchangeController', () => {
let controller: ExchangeController
beforeEach(() => {
vi.clearAllMocks()
controller = new ExchangeController()
mockList.mockReturnValue([])
})
describe('index()', () => {
it('returns 404 when device has never been traced and has no exchanges', async () => {
mockIsTraced.mockReturnValue(false)
mockHasExchanges.mockResolvedValue(false)
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const result = await controller.index(ctx)
expect(result.status).toBe(404)
expect(result.body.error).toContain('No trace exists')
})
it('returns 200 with empty array when device is traced but has no messages', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(false)
mockFindByDevice.mockResolvedValue([])
mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 })
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const result = await controller.index(ctx)
expect(result.status).toBe(200)
expect(result.body.exchanges).toEqual([])
expect(result.body.metadata.traceStatus).toBe('ACTIVE')
expect(result.body.metadata.total).toBe(0)
})
it('returns 200 with exchanges for actively traced device', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
const exchanges = [
{ deviceId: 'device-123', direction: 'CPE→ACS', timestamp: '2025-01-15T10:30:00.000Z', cwmpMethod: 'Inform' },
{ deviceId: 'device-123', direction: 'ACS→CPE', timestamp: '2025-01-15T10:30:01.000Z', cwmpMethod: 'InformResponse' },
]
mockFindByDevice.mockResolvedValue(exchanges)
mockGetMetadata.mockResolvedValue({
recordedFrom: '2025-01-15T10:30:00.000Z',
recordedUntil: '2025-01-15T10:30:01.000Z',
count: 2,
})
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const result = await controller.index(ctx)
expect(result.status).toBe(200)
expect(result.body.exchanges).toHaveLength(2)
expect(result.body.metadata.traceStatus).toBe('ACTIVE')
expect(result.body.metadata.traceStoppedAt).toBeNull()
expect(result.body.metadata.recordedFrom).toBe('2025-01-15T10:30:00.000Z')
expect(result.body.metadata.recordedUntil).toBe('2025-01-15T10:30:01.000Z')
expect(result.body.metadata.total).toBe(2)
})
it('returns 200 with STOPPED status for previously traced device', async () => {
mockIsTraced.mockReturnValue(false)
mockHasExchanges.mockResolvedValue(true)
const exchanges = [
{ deviceId: 'device-123', direction: 'CPE→ACS', timestamp: '2025-01-15T10:30:00.000Z', cwmpMethod: 'Inform' },
]
mockFindByDevice.mockResolvedValue(exchanges)
mockGetMetadata.mockResolvedValue({
recordedFrom: '2025-01-15T10:30:00.000Z',
recordedUntil: '2025-01-15T10:35:00.000Z',
count: 1,
})
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const result = await controller.index(ctx)
expect(result.status).toBe(200)
expect(result.body.metadata.traceStatus).toBe('STOPPED')
expect(result.body.metadata.traceStoppedAt).toBe('2025-01-15T10:35:00.000Z')
})
it('uses default limit of 200 when not specified', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
mockFindByDevice.mockResolvedValue([])
mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 })
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
await controller.index(ctx)
expect(mockFindByDevice).toHaveBeenCalledWith('device-123', { limit: 200, after: undefined, before: undefined, latest: false })
})
it('accepts valid limit parameter', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
mockFindByDevice.mockResolvedValue([])
mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 })
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '50' } })
await controller.index(ctx)
expect(mockFindByDevice).toHaveBeenCalledWith('device-123', { limit: 50, after: undefined, before: undefined, latest: false })
})
it('clamps limit to 1000', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
mockFindByDevice.mockResolvedValue([])
mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 })
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '5000' } })
await controller.index(ctx)
expect(mockFindByDevice).toHaveBeenCalledWith('device-123', { limit: 1000, after: undefined, before: undefined, latest: false })
})
it('returns 400 for non-numeric limit', async () => {
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: 'abc' } })
const result = await controller.index(ctx)
expect(result.status).toBe(400)
expect(result.body.error).toContain('Invalid limit')
})
it('returns 400 for limit less than 1', async () => {
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '0' } })
const result = await controller.index(ctx)
expect(result.status).toBe(400)
expect(result.body.error).toContain('Invalid limit')
})
it('returns 400 for negative limit', async () => {
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '-5' } })
const result = await controller.index(ctx)
expect(result.status).toBe(400)
expect(result.body.error).toContain('Invalid limit')
})
it('returns 400 for float limit', async () => {
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { limit: '2.5' } })
const result = await controller.index(ctx)
expect(result.status).toBe(400)
expect(result.body.error).toContain('Invalid limit')
})
it('accepts valid after parameter', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
mockFindByDevice.mockResolvedValue([])
mockGetMetadata.mockResolvedValue({ recordedFrom: null, recordedUntil: null, count: 0 })
const after = '2025-01-15T10:30:00.000Z'
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { after } })
await controller.index(ctx)
expect(mockFindByDevice).toHaveBeenCalledWith('device-123', { limit: 200, after, before: undefined, latest: false })
})
it('returns 400 for invalid after parameter', async () => {
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { after: 'not-a-date' } })
const result = await controller.index(ctx)
expect(result.status).toBe(400)
expect(result.body.error).toContain('Invalid after')
})
it('returns 400 for partial ISO 8601 timestamp in after', async () => {
const ctx = createHttpContext({ params: { deviceId: 'device-123' }, qs: { after: '2025-13-45' } })
const result = await controller.index(ctx)
expect(result.status).toBe(400)
expect(result.body.error).toContain('Invalid after')
})
it('returns partial results with isTruncated: true when query exceeds 5s timeout', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
// Simulate a slow query that never resolves within the timeout
mockFindByDevice.mockImplementation(() => new Promise((resolve) => {
setTimeout(() => resolve([{ deviceId: 'device-123', cwmpMethod: 'Inform' }]), 10_000)
}))
mockGetMetadata.mockResolvedValue({
recordedFrom: '2025-01-15T10:30:00.000Z',
recordedUntil: '2025-01-15T10:35:00.000Z',
count: 42,
})
vi.useFakeTimers()
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const resultPromise = controller.index(ctx)
// Advance timers past the 5s timeout
await vi.advanceTimersByTimeAsync(5001)
const result = await resultPromise
expect(result.status).toBe(200)
expect(result.body.exchanges).toEqual([])
expect(result.body.metadata.isTruncated).toBe(true)
expect(result.body.metadata.total).toBe(42)
vi.useRealTimers()
})
it('does not include isTruncated flag when query completes within timeout', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
const exchanges = [
{ deviceId: 'device-123', direction: 'CPE→ACS', timestamp: '2025-01-15T10:30:00.000Z', cwmpMethod: 'Inform' },
]
mockFindByDevice.mockResolvedValue(exchanges)
mockGetMetadata.mockResolvedValue({
recordedFrom: '2025-01-15T10:30:00.000Z',
recordedUntil: '2025-01-15T10:30:00.000Z',
count: 1,
})
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const result = await controller.index(ctx)
expect(result.status).toBe(200)
expect(result.body.exchanges).toHaveLength(1)
expect(result.body.metadata.isTruncated).toBeUndefined()
expect(result.body.metadata.total).toBe(1)
})
it('includes total count in metadata even when truncated', async () => {
mockIsTraced.mockReturnValue(true)
mockHasExchanges.mockResolvedValue(true)
mockFindByDevice.mockImplementation(() => new Promise((resolve) => {
setTimeout(() => resolve([]), 10_000)
}))
mockGetMetadata.mockResolvedValue({
recordedFrom: '2025-01-15T10:00:00.000Z',
recordedUntil: '2025-01-15T12:00:00.000Z',
count: 3500,
})
vi.useFakeTimers()
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const resultPromise = controller.index(ctx)
await vi.advanceTimersByTimeAsync(5001)
const result = await resultPromise
expect(result.status).toBe(200)
expect(result.body.metadata.isTruncated).toBe(true)
expect(result.body.metadata.total).toBe(3500)
vi.useRealTimers()
})
})
describe('destroy()', () => {
it('returns 204 with no body', async () => {
mockDeleteByDevice.mockResolvedValue(5)
const ctx = createHttpContext({ params: { deviceId: 'device-123' } })
const result = await controller.destroy(ctx)
expect(result.status).toBe(204)
expect(mockDeleteByDevice).toHaveBeenCalledWith('device-123')
})
it('returns 204 even when no exchanges existed', async () => {
mockDeleteByDevice.mockResolvedValue(0)
const ctx = createHttpContext({ params: { deviceId: 'device-456' } })
const result = await controller.destroy(ctx)
expect(result.status).toBe(204)
expect(mockDeleteByDevice).toHaveBeenCalledWith('device-456')
})
})
})
@@ -0,0 +1,369 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
// --- Mocks ---
const mockFindByDevice = vi.fn()
const mockHasExchanges = vi.fn()
const mockGetMetadata = vi.fn()
const mockDeleteByDevice = vi.fn()
const mockFindAfterSequence = vi.fn()
vi.mock('#models/exchange', () => ({
default: {
findByDevice: (...args: any[]) => mockFindByDevice(...args),
hasExchanges: (...args: any[]) => mockHasExchanges(...args),
getMetadata: (...args: any[]) => mockGetMetadata(...args),
deleteByDevice: (...args: any[]) => mockDeleteByDevice(...args),
findAfterSequence: (...args: any[]) => mockFindAfterSequence(...args),
},
}))
const mockIsTraced = vi.fn()
vi.mock('#services/tr069_trace_service', () => ({
Tr069TraceService: {
getInstance: () => ({
isTraced: mockIsTraced,
}),
},
}))
const mockEmitter = new EventEmitter()
const mockSubscribe = vi.fn((deviceId: string, listener: Function) => {
mockEmitter.on(`exchange:${deviceId}`, listener as any)
})
const mockUnsubscribe = vi.fn((deviceId: string, listener: Function) => {
mockEmitter.off(`exchange:${deviceId}`, listener as any)
})
vi.mock('#services/exchange_store_service', () => ({
ExchangeStoreService: {
getInstance: () => ({
subscribe: mockSubscribe,
unsubscribe: mockUnsubscribe,
}),
},
}))
vi.mock('#config/exchange_store', () => ({
default: {
maxMessagesPerDevice: 5000,
sse: {
inactivityTimeoutMs: 300_000,
absoluteTimeoutMs: 7_200_000,
maxConnectionsPerDevice: 5,
retryIntervalMs: 3000,
},
featureFlags: {
persistence: true,
sseEnabled: true,
apiSource: 'mongodb',
},
},
}))
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}))
import ExchangeController from '../../../app/controllers/exchange_controller.js'
// --- Helpers ---
function createStreamContext(options: {
deviceId: string
lastEventId?: string
}) {
const written: string[] = []
const headers: Record<string, string> = {}
let headWritten = false
let statusCode = 0
let ended = false
const reqEmitter = new EventEmitter()
const nodeRes = {
writeHead: vi.fn((code: number, hdrs: Record<string, string>) => {
statusCode = code
Object.assign(headers, hdrs)
headWritten = true
}),
write: vi.fn((chunk: string) => {
written.push(chunk)
}),
end: vi.fn(() => {
ended = true
}),
}
const responseObj = {
response: nodeRes,
status: vi.fn(function (this: any, code: number) {
statusCode = code
return this
}),
json: vi.fn((data: any) => data),
}
const ctx = {
params: { deviceId: options.deviceId },
request: {
header: vi.fn((name: string) => {
if (name === 'Last-Event-ID') return options.lastEventId
return undefined
}),
id: vi.fn(() => 'test-request-id'),
request: reqEmitter,
},
response: responseObj,
}
return {
ctx: ctx as any,
nodeRes,
reqEmitter,
getWritten: () => written,
getHeaders: () => headers,
isHeadWritten: () => headWritten,
isEnded: () => ended,
getStatusCode: () => statusCode,
}
}
describe('ExchangeController.stream()', () => {
let controller: ExchangeController
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
controller = new ExchangeController()
mockFindAfterSequence.mockResolvedValue([])
})
afterEach(() => {
vi.useRealTimers()
mockEmitter.removeAllListeners()
})
it('returns 404 if device is not traced', async () => {
mockIsTraced.mockReturnValue(false)
const { ctx, getStatusCode } = createStreamContext({ deviceId: 'device-unknown' })
await controller.stream(ctx)
expect(ctx.response.status).toHaveBeenCalledWith(404)
expect(ctx.response.json).toHaveBeenCalledWith({ error: 'Device is not being traced' })
})
it('sets correct SSE headers on traced device', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, getHeaders, getStatusCode } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
expect(getStatusCode()).toBe(200)
expect(getHeaders()).toEqual({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
})
})
it('sends retry interval as first SSE message', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, getWritten } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
expect(getWritten()[0]).toBe('retry: 3000\n\n')
})
it('subscribes to ExchangeStoreService events for the device', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
expect(mockSubscribe).toHaveBeenCalledWith('device-1', expect.any(Function))
})
it('pushes new exchanges as SSE events with id: sequenceNumber', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, getWritten } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
const exchange = {
deviceId: 'device-1',
direction: 'CPE→ACS',
timestamp: '2025-01-15T10:30:00.123Z',
sessionId: '10.0.0.1:54321->192.168.1.1:8080',
cwmpMethod: 'Inform',
rawXml: '<soap-env:Envelope />',
sequenceNumber: 42,
}
// Emit an exchange event
mockEmitter.emit('exchange:device-1', exchange)
const written = getWritten()
const sseEvent = written[written.length - 1]
expect(sseEvent).toBe(`id: 42\ndata: ${JSON.stringify(exchange)}\n\n`)
})
it('handles Last-Event-ID for reconnection — sends missed exchanges', async () => {
mockIsTraced.mockReturnValue(true)
const missedExchanges = [
{
deviceId: 'device-1',
direction: 'CPE→ACS',
timestamp: '2025-01-15T10:30:01.000Z',
sessionId: 'sess-1',
cwmpMethod: 'Inform',
rawXml: '<xml/>',
sequenceNumber: 6,
},
{
deviceId: 'device-1',
direction: 'ACS→CPE',
timestamp: '2025-01-15T10:30:02.000Z',
sessionId: 'sess-1',
cwmpMethod: 'InformResponse',
rawXml: '<xml/>',
sequenceNumber: 7,
},
]
mockFindAfterSequence.mockResolvedValue(missedExchanges)
const { ctx, getWritten } = createStreamContext({
deviceId: 'device-1',
lastEventId: '5',
})
await controller.stream(ctx)
expect(mockFindAfterSequence).toHaveBeenCalledWith('device-1', 5)
const written = getWritten()
// After retry message, should have 2 missed exchanges
expect(written[1]).toBe(`id: 6\ndata: ${JSON.stringify(missedExchanges[0])}\n\n`)
expect(written[2]).toBe(`id: 7\ndata: ${JSON.stringify(missedExchanges[1])}\n\n`)
})
it('closes connection on client disconnect', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, reqEmitter, isEnded } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
// Simulate client disconnect
reqEmitter.emit('close')
expect(isEnded()).toBe(true)
expect(mockUnsubscribe).toHaveBeenCalledWith('device-1', expect.any(Function))
})
it('closes connection after inactivity timeout (5min)', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, isEnded } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
expect(isEnded()).toBe(false)
// Advance time by 5 minutes (inactivity timeout)
vi.advanceTimersByTime(300_000)
expect(isEnded()).toBe(true)
expect(mockUnsubscribe).toHaveBeenCalledWith('device-1', expect.any(Function))
})
it('closes connection after absolute timeout (2h)', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, isEnded } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
// Send an exchange to reset inactivity timer at 4min
vi.advanceTimersByTime(240_000)
mockEmitter.emit('exchange:device-1', {
deviceId: 'device-1',
direction: 'CPE→ACS',
timestamp: new Date().toISOString(),
sessionId: 'sess',
cwmpMethod: 'Inform',
rawXml: '<xml/>',
sequenceNumber: 1,
})
expect(isEnded()).toBe(false)
// Advance to absolute timeout (2h from start)
vi.advanceTimersByTime(7_200_000 - 240_000)
expect(isEnded()).toBe(true)
})
it('resets inactivity timer when a new exchange is pushed', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, isEnded } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
// Advance 4 minutes (just under the 5min inactivity timeout)
vi.advanceTimersByTime(240_000)
expect(isEnded()).toBe(false)
// Emit an exchange event → resets inactivity timer
mockEmitter.emit('exchange:device-1', {
deviceId: 'device-1',
direction: 'CPE→ACS',
timestamp: new Date().toISOString(),
sessionId: 'sess',
cwmpMethod: 'Inform',
rawXml: '<xml/>',
sequenceNumber: 1,
})
// Advance another 4 minutes (still within reset inactivity window)
vi.advanceTimersByTime(240_000)
expect(isEnded()).toBe(false)
// Advance 1 more minute to exceed the 5min inactivity window
vi.advanceTimersByTime(60_001)
expect(isEnded()).toBe(true)
})
it('does not push events after connection is closed', async () => {
mockIsTraced.mockReturnValue(true)
const { ctx, reqEmitter, nodeRes } = createStreamContext({ deviceId: 'device-1' })
await controller.stream(ctx)
// Close connection
reqEmitter.emit('close')
// Clear the write mock to check no further writes
nodeRes.write.mockClear()
// Emit exchange after close
mockEmitter.emit('exchange:device-1', {
deviceId: 'device-1',
direction: 'CPE→ACS',
timestamp: new Date().toISOString(),
sessionId: 'sess',
cwmpMethod: 'Inform',
rawXml: '<xml/>',
sequenceNumber: 99,
})
expect(nodeRes.write).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,295 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock dependencies before importing the controller
vi.mock('#providers/mongodb_provider', () => ({
getMongoDb: vi.fn(),
}))
vi.mock('#services/exchange_index_checker', () => ({
verifyCwmpExchangesIndexes: vi.fn(),
}))
vi.mock('#services/exchange_store_service', () => ({
ExchangeStoreService: {
getInstance: vi.fn(),
},
}))
import { getMongoDb } from '#providers/mongodb_provider'
import { verifyCwmpExchangesIndexes } from '#services/exchange_index_checker'
import { ExchangeStoreService } from '#services/exchange_store_service'
import HealthController from '#controllers/health_controller'
describe('HealthController.checkTr069Exchanges', () => {
let controller: HealthController
let mockResponse: any
let mockContext: any
beforeEach(() => {
vi.clearAllMocks()
controller = new HealthController()
mockResponse = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
}
mockContext = {
response: mockResponse,
}
})
it('returns healthy status when MongoDB is connected with low latency and all indexes present', async () => {
const mockDb = {
command: vi.fn().mockResolvedValue({ ok: 1 }),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(500),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [
{ name: 'idx_deviceId_timestamp', description: 'Compound index', exists: true },
{ name: 'idx_deviceId_sequenceNumber', description: 'Unique index', exists: true },
{ name: 'idx_createdAt_ttl', description: 'TTL index', exists: true },
],
})
// Mock collStats command
mockDb.command.mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.resolve({ size: 1048576 }) // 1MB
return Promise.resolve({})
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 3,
} as any)
await controller.checkTr069Exchanges(mockContext)
expect(mockResponse.status).toHaveBeenCalledWith(200)
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
status: 'healthy',
mongodb: expect.objectContaining({
connected: true,
latency_ms: expect.any(Number),
collections: {
cwmp_exchanges: {
count: 500,
size_mb: expect.any(Number),
},
},
indexes: expect.objectContaining({
allPresent: true,
}),
}),
sse: {
active_connections: 3,
},
timestamp: expect.any(String),
})
)
})
it('returns unhealthy status when MongoDB is unreachable', async () => {
vi.mocked(getMongoDb).mockImplementation(() => {
throw new Error('MongoDB is not connected')
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 0,
} as any)
await controller.checkTr069Exchanges(mockContext)
expect(mockResponse.status).toHaveBeenCalledWith(503)
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
status: 'unhealthy',
mongodb: expect.objectContaining({
connected: false,
latency_ms: null,
collections: null,
indexes: null,
}),
})
)
})
it('returns degraded status when MongoDB latency is >= 100ms', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) {
// Simulate a delay >= 100ms by manipulating Date.now
return new Promise((resolve) => setTimeout(() => resolve({ ok: 1 }), 105))
}
if (cmd.collStats) return Promise.resolve({ size: 2097152 })
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(1000),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 5,
} as any)
await controller.checkTr069Exchanges(mockContext)
// The test may be flaky due to timing, so we check both possible outcomes
const jsonCall = mockResponse.json.mock.calls[0][0]
expect(jsonCall.mongodb.connected).toBe(true)
// Latency >= 100ms would be degraded, otherwise healthy
if (jsonCall.mongodb.latency_ms >= 100) {
expect(jsonCall.status).toBe('degraded')
expect(mockResponse.status).toHaveBeenCalledWith(200)
} else {
expect(jsonCall.status).toBe('healthy')
}
})
it('returns degraded status when indexes are missing', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.resolve({ size: 512000 })
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(200),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: false,
indexes: [
{ name: 'idx_deviceId_timestamp', description: 'Compound index', exists: true },
{ name: 'idx_deviceId_sequenceNumber', description: 'Unique index', exists: false },
{ name: 'idx_createdAt_ttl', description: 'TTL index', exists: true },
],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 1,
} as any)
await controller.checkTr069Exchanges(mockContext)
expect(mockResponse.status).toHaveBeenCalledWith(200)
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
status: 'degraded',
mongodb: expect.objectContaining({
connected: true,
indexes: expect.objectContaining({
allPresent: false,
}),
}),
})
)
})
it('reports SSE active connection count from ExchangeStoreService', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.resolve({ size: 0 })
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(0),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 42,
} as any)
await controller.checkTr069Exchanges(mockContext)
const jsonCall = mockResponse.json.mock.calls[0][0]
expect(jsonCall.sse.active_connections).toBe(42)
})
it('handles collStats failure gracefully (size defaults to 0)', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.reject(new Error('ns not found'))
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(0),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 0,
} as any)
await controller.checkTr069Exchanges(mockContext)
expect(mockResponse.status).toHaveBeenCalledWith(200)
const jsonCall = mockResponse.json.mock.calls[0][0]
expect(jsonCall.mongodb.collections.cwmp_exchanges.size_mb).toBe(0)
})
it('includes a valid ISO 8601 timestamp in the response', async () => {
const mockDb = {
command: vi.fn().mockImplementation((cmd: any) => {
if (cmd.ping) return Promise.resolve({ ok: 1 })
if (cmd.collStats) return Promise.resolve({ size: 0 })
return Promise.resolve({})
}),
collection: vi.fn().mockReturnValue({
estimatedDocumentCount: vi.fn().mockResolvedValue(0),
}),
}
vi.mocked(getMongoDb).mockReturnValue(mockDb as any)
vi.mocked(verifyCwmpExchangesIndexes).mockResolvedValue({
collection: 'cwmp_exchanges',
allPresent: true,
indexes: [],
})
vi.mocked(ExchangeStoreService.getInstance).mockReturnValue({
activeConnectionCount: 0,
} as any)
await controller.checkTr069Exchanges(mockContext)
const jsonCall = mockResponse.json.mock.calls[0][0]
const date = new Date(jsonCall.timestamp)
expect(date.getTime()).not.toBeNaN()
})
})
@@ -0,0 +1,145 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { verifyCwmpExchangesIndexes, verifyIndexesAtStartup } from '../../../app/services/exchange_index_checker.js'
describe('exchange_index_checker', () => {
describe('verifyCwmpExchangesIndexes', () => {
it('should report all indexes present when they all exist', async () => {
const mockDb = {
collection: vi.fn().mockReturnValue({
indexes: vi.fn().mockResolvedValue([
{ name: '_id_', key: { _id: 1 } },
{ name: 'idx_deviceId_timestamp', key: { deviceId: 1, timestamp: 1 } },
{ name: 'idx_deviceId_sequenceNumber', key: { deviceId: 1, sequenceNumber: 1 } },
{ name: 'idx_createdAt_ttl', key: { createdAt: 1 } },
]),
}),
} as any
const result = await verifyCwmpExchangesIndexes(mockDb)
expect(result.collection).toBe('cwmp_exchanges')
expect(result.allPresent).toBe(true)
expect(result.indexes).toHaveLength(3)
expect(result.indexes.every((idx) => idx.exists)).toBe(true)
})
it('should report missing indexes when some are absent', async () => {
const mockDb = {
collection: vi.fn().mockReturnValue({
indexes: vi.fn().mockResolvedValue([
{ name: '_id_', key: { _id: 1 } },
{ name: 'idx_deviceId_timestamp', key: { deviceId: 1, timestamp: 1 } },
]),
}),
} as any
const result = await verifyCwmpExchangesIndexes(mockDb)
expect(result.allPresent).toBe(false)
expect(result.indexes[0]).toEqual({
name: 'idx_deviceId_timestamp',
description: 'Compound index (deviceId, timestamp)',
exists: true,
})
expect(result.indexes[1]).toEqual({
name: 'idx_deviceId_sequenceNumber',
description: 'Unique index (deviceId, sequenceNumber)',
exists: false,
})
expect(result.indexes[2]).toEqual({
name: 'idx_createdAt_ttl',
description: 'TTL index (createdAt, 86400s)',
exists: false,
})
})
it('should report all indexes missing when collection does not exist', async () => {
const mockDb = {
collection: vi.fn().mockReturnValue({
indexes: vi.fn().mockRejectedValue(new Error('ns not found')),
}),
} as any
const result = await verifyCwmpExchangesIndexes(mockDb)
expect(result.allPresent).toBe(false)
expect(result.indexes.every((idx) => !idx.exists)).toBe(true)
})
it('should report all indexes missing when no custom indexes exist', async () => {
const mockDb = {
collection: vi.fn().mockReturnValue({
indexes: vi.fn().mockResolvedValue([{ name: '_id_', key: { _id: 1 } }]),
}),
} as any
const result = await verifyCwmpExchangesIndexes(mockDb)
expect(result.allPresent).toBe(false)
expect(result.indexes.every((idx) => !idx.exists)).toBe(true)
})
})
describe('verifyIndexesAtStartup', () => {
let mockLog: { warn: ReturnType<typeof vi.fn>; info: ReturnType<typeof vi.fn> }
beforeEach(() => {
mockLog = {
warn: vi.fn(),
info: vi.fn(),
}
})
it('should log info when all indexes are present', async () => {
const mockDb = {
collection: vi.fn().mockReturnValue({
indexes: vi.fn().mockResolvedValue([
{ name: '_id_', key: { _id: 1 } },
{ name: 'idx_deviceId_timestamp', key: { deviceId: 1, timestamp: 1 } },
{ name: 'idx_deviceId_sequenceNumber', key: { deviceId: 1, sequenceNumber: 1 } },
{ name: 'idx_createdAt_ttl', key: { createdAt: 1 } },
]),
}),
} as any
await verifyIndexesAtStartup(mockDb, mockLog)
expect(mockLog.info).toHaveBeenCalledWith('[ExchangeIndexCheck] All cwmp_exchanges indexes verified OK')
expect(mockLog.warn).not.toHaveBeenCalled()
})
it('should log warnings for each missing index', async () => {
const mockDb = {
collection: vi.fn().mockReturnValue({
indexes: vi.fn().mockResolvedValue([
{ name: '_id_', key: { _id: 1 } },
{ name: 'idx_deviceId_timestamp', key: { deviceId: 1, timestamp: 1 } },
]),
}),
} as any
await verifyIndexesAtStartup(mockDb, mockLog)
expect(mockLog.info).not.toHaveBeenCalled()
expect(mockLog.warn).toHaveBeenCalledTimes(2)
expect(mockLog.warn).toHaveBeenCalledWith(
'[ExchangeIndexCheck] Missing index on cwmp_exchanges: idx_deviceId_sequenceNumber (Unique index (deviceId, sequenceNumber))'
)
expect(mockLog.warn).toHaveBeenCalledWith(
'[ExchangeIndexCheck] Missing index on cwmp_exchanges: idx_createdAt_ttl (TTL index (createdAt, 86400s))'
)
})
it('should log warnings for all indexes when collection does not exist', async () => {
const mockDb = {
collection: vi.fn().mockReturnValue({
indexes: vi.fn().mockRejectedValue(new Error('ns not found')),
}),
} as any
await verifyIndexesAtStartup(mockDb, mockLog)
expect(mockLog.warn).toHaveBeenCalledTimes(3)
})
})
})
@@ -0,0 +1,269 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ExchangeMetricsService } from '#services/exchange_metrics_service'
import type { MetricsSummary } from '#services/exchange_metrics_service'
// Mock the logger
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}))
describe('ExchangeMetricsService', () => {
beforeEach(() => {
ExchangeMetricsService.resetInstance()
})
afterEach(() => {
ExchangeMetricsService.resetInstance()
})
describe('singleton', () => {
it('should return the same instance on multiple calls', () => {
const instance1 = ExchangeMetricsService.getInstance()
const instance2 = ExchangeMetricsService.getInstance()
expect(instance1).toBe(instance2)
})
it('should create a fresh instance after resetInstance', () => {
const instance1 = ExchangeMetricsService.getInstance()
ExchangeMetricsService.resetInstance()
const instance2 = ExchangeMetricsService.getInstance()
expect(instance1).not.toBe(instance2)
})
})
describe('recordMongoWriteLatency', () => {
it('should record samples and compute correct percentiles', () => {
const metrics = ExchangeMetricsService.getInstance()
// Record 100 samples: 1, 2, 3, ..., 100
for (let i = 1; i <= 100; i++) {
metrics.recordMongoWriteLatency(i)
}
const aggregated = metrics.getAggregatedMetrics()
expect(aggregated.mongoWriteLatency).not.toBeNull()
const summary = aggregated.mongoWriteLatency!
expect(summary.count).toBe(100)
expect(summary.min).toBe(1)
expect(summary.max).toBe(100)
expect(summary.p50).toBe(50)
expect(summary.p95).toBe(95)
expect(summary.p99).toBe(99)
expect(summary.avg).toBe(50.5)
})
it('should return null when no samples exist', () => {
const metrics = ExchangeMetricsService.getInstance()
const aggregated = metrics.getAggregatedMetrics()
expect(aggregated.mongoWriteLatency).toBeNull()
})
})
describe('recordSsePushLatency', () => {
it('should record and compute SSE push latency percentiles', () => {
const metrics = ExchangeMetricsService.getInstance()
metrics.recordSsePushLatency(0.5)
metrics.recordSsePushLatency(1.2)
metrics.recordSsePushLatency(2.8)
metrics.recordSsePushLatency(0.3)
metrics.recordSsePushLatency(15.0)
const aggregated = metrics.getAggregatedMetrics()
const summary = aggregated.ssePushLatency!
expect(summary.count).toBe(5)
expect(summary.min).toBe(0.3)
expect(summary.max).toBe(15.0)
})
})
describe('recordApiResponseTime', () => {
it('should track separate metrics per endpoint', () => {
const metrics = ExchangeMetricsService.getInstance()
metrics.recordApiResponseTime('GET /exchanges', 10)
metrics.recordApiResponseTime('GET /exchanges', 20)
metrics.recordApiResponseTime('DELETE /exchanges', 5)
const aggregated = metrics.getAggregatedMetrics()
expect(aggregated.apiResponseTimes['GET /exchanges']).toBeDefined()
expect(aggregated.apiResponseTimes['GET /exchanges'].count).toBe(2)
expect(aggregated.apiResponseTimes['GET /exchanges'].avg).toBe(15)
expect(aggregated.apiResponseTimes['DELETE /exchanges']).toBeDefined()
expect(aggregated.apiResponseTimes['DELETE /exchanges'].count).toBe(1)
expect(aggregated.apiResponseTimes['DELETE /exchanges'].avg).toBe(5)
})
it('should handle new endpoint names dynamically', () => {
const metrics = ExchangeMetricsService.getInstance()
metrics.recordApiResponseTime('GET /exchanges/stream', 42)
const aggregated = metrics.getAggregatedMetrics()
expect(aggregated.apiResponseTimes['GET /exchanges/stream']).toBeDefined()
expect(aggregated.apiResponseTimes['GET /exchanges/stream'].count).toBe(1)
})
})
describe('computeSummary', () => {
it('should return null for empty array', () => {
const metrics = ExchangeMetricsService.getInstance()
const result = metrics.computeSummary([])
expect(result).toBeNull()
})
it('should handle single sample', () => {
const metrics = ExchangeMetricsService.getInstance()
const result = metrics.computeSummary([42])!
expect(result.count).toBe(1)
expect(result.p50).toBe(42)
expect(result.p95).toBe(42)
expect(result.p99).toBe(42)
expect(result.min).toBe(42)
expect(result.max).toBe(42)
expect(result.avg).toBe(42)
})
it('should compute correct percentiles for known distribution', () => {
const metrics = ExchangeMetricsService.getInstance()
// 10 samples: 1..10
const samples = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
const result = metrics.computeSummary(samples)!
expect(result.count).toBe(10)
expect(result.min).toBe(1)
expect(result.max).toBe(10)
expect(result.p50).toBe(5)
expect(result.p95).toBe(10) // ceil(0.95 * 10) - 1 = index 9 = 10
expect(result.p99).toBe(10) // ceil(0.99 * 10) - 1 = index 9 = 10
expect(result.avg).toBe(5.5)
})
it('should handle unordered input', () => {
const metrics = ExchangeMetricsService.getInstance()
const samples = [5, 1, 3, 2, 4]
const result = metrics.computeSummary(samples)!
expect(result.min).toBe(1)
expect(result.max).toBe(5)
expect(result.p50).toBe(3) // ceil(0.5*5)-1 = index 2 = 3 (sorted: 1,2,3,4,5)
})
})
describe('flush', () => {
it('should reset all samples after flush', () => {
const metrics = ExchangeMetricsService.getInstance()
metrics.recordMongoWriteLatency(10)
metrics.recordSsePushLatency(5)
metrics.recordApiResponseTime('GET /exchanges', 20)
metrics.flush()
const aggregated = metrics.getAggregatedMetrics()
expect(aggregated.mongoWriteLatency).toBeNull()
expect(aggregated.ssePushLatency).toBeNull()
expect(Object.keys(aggregated.apiResponseTimes)).toHaveLength(0)
})
it('should return aggregated metrics at time of flush', () => {
const metrics = ExchangeMetricsService.getInstance()
metrics.recordMongoWriteLatency(10)
metrics.recordMongoWriteLatency(20)
const flushed = metrics.flush()
expect(flushed.mongoWriteLatency).not.toBeNull()
expect(flushed.mongoWriteLatency!.count).toBe(2)
})
it('should log metrics when there is data', async () => {
const loggerModule = await import('@adonisjs/core/services/logger')
const mockLogger = loggerModule.default
const metrics = ExchangeMetricsService.getInstance()
metrics.recordMongoWriteLatency(10)
metrics.flush()
expect(mockLogger.info).toHaveBeenCalledWith(
expect.objectContaining({ metrics: expect.any(Object) }),
'[ExchangeMetrics] Aggregated performance metrics'
)
})
it('should not log when there is no data', async () => {
const loggerModule = await import('@adonisjs/core/services/logger')
const mockLogger = loggerModule.default
vi.clearAllMocks()
const metrics = ExchangeMetricsService.getInstance()
metrics.flush()
expect(mockLogger.info).not.toHaveBeenCalled()
})
})
describe('getAggregatedMetrics', () => {
it('should include collectedAt ISO timestamp', () => {
const metrics = ExchangeMetricsService.getInstance()
const aggregated = metrics.getAggregatedMetrics()
expect(aggregated.collectedAt).toBeDefined()
// Verify it's a valid ISO 8601 date
const date = new Date(aggregated.collectedAt)
expect(date.getTime()).not.toBeNaN()
})
it('should include windowMs representing the collection window', () => {
const metrics = ExchangeMetricsService.getInstance()
const aggregated = metrics.getAggregatedMetrics()
expect(aggregated.windowMs).toBeGreaterThanOrEqual(0)
})
})
describe('max samples cap', () => {
it('should evict oldest samples when max is exceeded', () => {
// Use a small max for testing
ExchangeMetricsService.resetInstance()
const metrics = ExchangeMetricsService.getInstance(60_000, 5)
// Record 7 samples: only last 5 should be kept
for (let i = 1; i <= 7; i++) {
metrics.recordMongoWriteLatency(i)
}
const aggregated = metrics.getAggregatedMetrics()
const summary = aggregated.mongoWriteLatency!
expect(summary.count).toBe(5)
expect(summary.min).toBe(3) // oldest (1,2) evicted
expect(summary.max).toBe(7)
})
})
describe('start/stop', () => {
it('should start and stop without errors', () => {
const metrics = ExchangeMetricsService.getInstance()
expect(() => metrics.start()).not.toThrow()
expect(() => metrics.stop()).not.toThrow()
})
it('should be idempotent on multiple starts', () => {
const metrics = ExchangeMetricsService.getInstance()
metrics.start()
metrics.start() // second call should not create duplicate timers
metrics.stop()
})
})
})
@@ -0,0 +1,434 @@
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 }
)
})
})
@@ -0,0 +1,300 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// 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 } from '../../../app/services/exchange_store_service.js'
import type { CwmpExchange } from '../../../app/models/exchange.js'
describe('ExchangeStoreService — persistWithRetry', () => {
let service: ExchangeStoreService
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset singleton for testing
;(ExchangeStoreService as any).instance = undefined
service = ExchangeStoreService.getInstance()
})
afterEach(() => {
vi.useRealTimers()
})
const makeCriticalDoc = (method: string = 'Inform'): CwmpExchange => ({
deviceId: 'device-001',
direction: 'CPE→ACS',
timestamp: new Date().toISOString(),
sessionId: '10.0.0.1:54321->192.168.1.1:8080',
cwmpMethod: method,
rawXml: '<soap:Envelope><cwmp:Inform/></soap:Envelope>',
sequenceNumber: 1,
createdAt: new Date(),
})
const makeNonCriticalDoc = (method: string = 'Empty'): CwmpExchange => ({
deviceId: 'device-001',
direction: 'CPE→ACS',
timestamp: new Date().toISOString(),
sessionId: '10.0.0.1:54321->192.168.1.1:8080',
cwmpMethod: method,
rawXml: '',
sequenceNumber: 1,
createdAt: new Date(),
})
describe('isCriticalMethod', () => {
it('classifies Inform as critical', () => {
expect(service.isCriticalMethod('Inform')).toBe(true)
})
it('classifies GetParameterValues as critical', () => {
expect(service.isCriticalMethod('GetParameterValues')).toBe(true)
})
it('classifies GetParameterValuesResponse as critical', () => {
expect(service.isCriticalMethod('GetParameterValuesResponse')).toBe(true)
})
it('classifies SetParameterValues as critical', () => {
expect(service.isCriticalMethod('SetParameterValues')).toBe(true)
})
it('classifies SetParameterValuesResponse as critical', () => {
expect(service.isCriticalMethod('SetParameterValuesResponse')).toBe(true)
})
it('classifies AddObject as critical', () => {
expect(service.isCriticalMethod('AddObject')).toBe(true)
})
it('classifies DeleteObject as critical', () => {
expect(service.isCriticalMethod('DeleteObject')).toBe(true)
})
it('classifies Download as critical', () => {
expect(service.isCriticalMethod('Download')).toBe(true)
})
it('classifies Upload as critical', () => {
expect(service.isCriticalMethod('Upload')).toBe(true)
})
it('classifies Reboot as critical', () => {
expect(service.isCriticalMethod('Reboot')).toBe(true)
})
it('classifies FactoryReset as critical', () => {
expect(service.isCriticalMethod('FactoryReset')).toBe(true)
})
it('classifies Fault as critical', () => {
expect(service.isCriticalMethod('Fault')).toBe(true)
})
it('classifies Empty as non-critical', () => {
expect(service.isCriticalMethod('Empty')).toBe(false)
})
it('classifies Unknown as non-critical', () => {
expect(service.isCriticalMethod('Unknown')).toBe(false)
})
})
describe('critical messages — retry behavior', () => {
it('succeeds on first attempt without retries', async () => {
insertOneMock.mockResolvedValueOnce({ insertedId: 'abc' })
await service.persistWithRetry(makeCriticalDoc(), true)
expect(insertOneMock).toHaveBeenCalledTimes(1)
})
it('retries up to 3 times with exponential backoff on failure', async () => {
const error = new Error('MongoDB write failed')
insertOneMock.mockRejectedValue(error)
const promise = service.persistWithRetry(makeCriticalDoc(), true)
// Initial attempt fails immediately
await vi.advanceTimersByTimeAsync(0)
// Retry 1: after 1s delay (baseDelayMs * 2^0 = 1000ms)
await vi.advanceTimersByTimeAsync(1000)
// Retry 2: after 2s delay (baseDelayMs * 2^1 = 2000ms)
await vi.advanceTimersByTimeAsync(2000)
// Retry 3: after 4s delay (baseDelayMs * 2^2 = 4000ms)
await vi.advanceTimersByTimeAsync(4000)
await promise
// 1 initial attempt + 3 retries = 4 total
expect(insertOneMock).toHaveBeenCalledTimes(4)
})
it('succeeds on second retry attempt', async () => {
const error = new Error('MongoDB write failed')
// Initial attempt fails
insertOneMock.mockRejectedValueOnce(error)
// Retry 1 fails
insertOneMock.mockRejectedValueOnce(error)
// Retry 2 succeeds
insertOneMock.mockResolvedValueOnce({ insertedId: 'abc' })
const promise = service.persistWithRetry(makeCriticalDoc(), true)
// Initial attempt fails
await vi.advanceTimersByTimeAsync(0)
// Retry 1 delay: 1000ms
await vi.advanceTimersByTimeAsync(1000)
// Retry 2 delay: 2000ms
await vi.advanceTimersByTimeAsync(2000)
await promise
// 1 initial + 2 retries = 3 calls
expect(insertOneMock).toHaveBeenCalledTimes(3)
})
it('never throws even when all retries are exhausted', async () => {
insertOneMock.mockRejectedValue(new Error('persistent failure'))
const promise = service.persistWithRetry(makeCriticalDoc(), true)
await vi.advanceTimersByTimeAsync(10000)
// Should not throw
await expect(promise).resolves.toBeUndefined()
})
it('logs error when all retries exhausted', async () => {
insertOneMock.mockRejectedValue(new Error('persistent failure'))
const promise = service.persistWithRetry(makeCriticalDoc('Inform'), true)
await vi.advanceTimersByTimeAsync(10000)
await promise
expect(loggerMock.error).toHaveBeenCalledWith(
expect.objectContaining({
deviceId: 'device-001',
cwmpMethod: 'Inform',
maxAttempts: 3,
}),
'[ExchangeStore] All retries exhausted for critical message — discarding'
)
})
})
describe('non-critical messages — discard immediately', () => {
it('discards immediately on first failure (zero retries)', async () => {
insertOneMock.mockRejectedValueOnce(new Error('write error'))
await service.persistWithRetry(makeNonCriticalDoc('Empty'), false)
// Only 1 attempt, no retries
expect(insertOneMock).toHaveBeenCalledTimes(1)
})
it('never throws for non-critical messages', async () => {
insertOneMock.mockRejectedValueOnce(new Error('write error'))
await expect(
service.persistWithRetry(makeNonCriticalDoc('Unknown'), false)
).resolves.toBeUndefined()
})
it('persists successfully on first attempt when no error', async () => {
insertOneMock.mockResolvedValueOnce({ insertedId: 'abc' })
await service.persistWithRetry(makeNonCriticalDoc(), false)
expect(insertOneMock).toHaveBeenCalledTimes(1)
})
it('logs warning on failure for non-critical messages', async () => {
insertOneMock.mockRejectedValueOnce(new Error('write error'))
await service.persistWithRetry(makeNonCriticalDoc('Empty'), false)
expect(loggerMock.warn).toHaveBeenCalledWith(
expect.objectContaining({
deviceId: 'device-001',
cwmpMethod: 'Empty',
}),
'[ExchangeStore] Non-critical message persistence failed — discarding'
)
})
})
describe('CWMP session never interrupted', () => {
it('critical message failure does not throw', async () => {
insertOneMock.mockRejectedValue(new Error('catastrophic failure'))
const promise = service.persistWithRetry(makeCriticalDoc('Inform'), true)
await vi.advanceTimersByTimeAsync(10000)
await expect(promise).resolves.toBeUndefined()
})
it('non-critical message failure does not throw', async () => {
insertOneMock.mockRejectedValue(new Error('catastrophic failure'))
await expect(
service.persistWithRetry(makeNonCriticalDoc('Empty'), false)
).resolves.toBeUndefined()
})
})
})
@@ -0,0 +1,265 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockInsertOne = vi.fn().mockResolvedValue({ insertedId: 'mock-id' })
const mockCollection = vi.fn().mockReturnValue({ insertOne: mockInsertOne })
const mockFindOneAndUpdate = vi.fn().mockResolvedValue({ seq: 1 })
const mockCountersCollection = { findOneAndUpdate: mockFindOneAndUpdate }
vi.mock('#providers/mongodb_provider', () => ({
getMongoDb: () => ({
collection: (name: string) => {
if (name === 'cwmp_exchange_counters') return mockCountersCollection
return { insertOne: mockInsertOne }
},
}),
}))
vi.mock('#models/exchange', () => ({
default: {
collectionName: 'cwmp_exchanges',
getCollection: () => ({ insertOne: mockInsertOne }),
countByDevice: vi.fn().mockResolvedValue(0),
deleteOldestBeyondCap: vi.fn().mockResolvedValue(undefined),
},
}))
vi.mock('#config/exchange_store', () => ({
default: {
maxMessagesPerDevice: 5000,
retry: {
maxAttempts: 3,
baseDelayMs: 1, // Use 1ms for fast tests
},
featureFlags: {
persistence: true,
sseEnabled: true,
apiSource: 'mongodb',
},
},
}))
vi.mock('@adonisjs/core/services/logger', () => ({
default: {
warn: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}))
import { ExchangeStoreService } from '../../../app/services/exchange_store_service.js'
describe('ExchangeStoreService', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFindOneAndUpdate.mockResolvedValue({ seq: 1 })
;(ExchangeStoreService as any).instance = undefined
})
describe('getInstance', () => {
it('returns the same instance on multiple calls', () => {
const a = ExchangeStoreService.getInstance()
const b = ExchangeStoreService.getInstance()
expect(a).toBe(b)
})
})
describe('extractCwmpMethod', () => {
it('returns "Empty" for empty string', () => {
const service = ExchangeStoreService.getInstance()
expect(service.extractCwmpMethod('')).toBe('Empty')
})
it('returns "Empty" for whitespace-only string', () => {
const service = ExchangeStoreService.getInstance()
expect(service.extractCwmpMethod(' \n\t ')).toBe('Empty')
})
it('extracts Inform from a valid SOAP body', () => {
const service = ExchangeStoreService.getInstance()
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/" xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Body>
<cwmp:Inform>
<DeviceId>test</DeviceId>
</cwmp:Inform>
</soap-env:Body>
</soap-env:Envelope>`
expect(service.extractCwmpMethod(xml)).toBe('Inform')
})
it('extracts GetParameterValuesResponse', () => {
const service = ExchangeStoreService.getInstance()
const xml = `<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Body>
<cwmp:GetParameterValuesResponse>
<ParameterList/>
</cwmp:GetParameterValuesResponse>
</soap-env:Body>
</soap-env:Envelope>`
expect(service.extractCwmpMethod(xml)).toBe('GetParameterValuesResponse')
})
it('returns "Unknown" when no cwmp: namespace element is found', () => {
const service = ExchangeStoreService.getInstance()
const xml = `<soap-env:Envelope>
<soap-env:Body>
<someOtherElement>data</someOtherElement>
</soap-env:Body>
</soap-env:Envelope>`
expect(service.extractCwmpMethod(xml)).toBe('Unknown')
})
it('returns "Unknown" for malformed XML without cwmp namespace', () => {
const service = ExchangeStoreService.getInstance()
expect(service.extractCwmpMethod('<broken><xml')).toBe('Unknown')
})
it('extracts method from self-closing cwmp element', () => {
const service = ExchangeStoreService.getInstance()
const xml = `<soap-env:Envelope xmlns:cwmp="urn:dslforum-org:cwmp-1-0">
<soap-env:Body><cwmp:TransferComplete/></soap-env:Body>
</soap-env:Envelope>`
expect(service.extractCwmpMethod(xml)).toBe('TransferComplete')
})
})
describe('persistExchange', () => {
it('persists a valid exchange and returns the document', async () => {
const service = ExchangeStoreService.getInstance()
const result = await service.persistExchange({
deviceId: 'device-001',
direction: 'CPE→ACS',
sessionId: '10.0.0.1:1234->192.168.1.1:8080',
rawXml: '<cwmp:Inform><DeviceId/></cwmp:Inform>',
})
expect(result).not.toBeNull()
expect(result!.deviceId).toBe('device-001')
expect(result!.direction).toBe('CPE→ACS')
expect(result!.cwmpMethod).toBe('Inform')
expect(result!.sequenceNumber).toBe(1)
expect(result!.rawXml).toBe('<cwmp:Inform><DeviceId/></cwmp:Inform>')
expect(mockInsertOne).toHaveBeenCalledTimes(1)
})
it('emits an event after persisting', async () => {
const service = ExchangeStoreService.getInstance()
const listener = vi.fn()
service.subscribe('device-002', listener)
await service.persistExchange({
deviceId: 'device-002',
direction: 'ACS→CPE',
sessionId: 'session-1',
rawXml: '<cwmp:GetParameterValues/>',
})
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
deviceId: 'device-002',
cwmpMethod: 'GetParameterValues',
})
)
service.unsubscribe('device-002', listener)
})
it('returns null when persistence is disabled', async () => {
// Re-mock config with persistence disabled
const configModule = await import('#config/exchange_store')
const originalPersistence = configModule.default.featureFlags.persistence
configModule.default.featureFlags.persistence = false
const service = ExchangeStoreService.getInstance()
const result = await service.persistExchange({
deviceId: 'device-003',
direction: 'CPE→ACS',
sessionId: 'session-2',
rawXml: '<cwmp:Inform/>',
})
expect(result).toBeNull()
expect(mockInsertOne).not.toHaveBeenCalled()
// Restore
configModule.default.featureFlags.persistence = originalPersistence
})
it('returns exchange even when MongoDB write fails (fire-and-forget)', async () => {
mockInsertOne.mockRejectedValue(new Error('MongoDB write failed'))
const service = ExchangeStoreService.getInstance()
const result = await service.persistExchange({
deviceId: 'device-004',
direction: 'CPE→ACS',
sessionId: 'session-3',
rawXml: '<cwmp:Inform/>',
})
// persistExchange never throws and returns the exchange object
// because persistWithRetry absorbs all errors (fire-and-forget design)
expect(result).not.toBeNull()
expect(result!.deviceId).toBe('device-004')
expect(result!.cwmpMethod).toBe('Inform')
mockInsertOne.mockResolvedValue({ insertedId: 'mock-id' })
})
})
describe('getNextSequenceNumber', () => {
it('returns the incremented sequence number', async () => {
mockFindOneAndUpdate.mockResolvedValueOnce({ seq: 42 })
const service = ExchangeStoreService.getInstance()
const seq = await service.getNextSequenceNumber('device-100')
expect(seq).toBe(42)
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
{ _id: 'device-100' },
{ $inc: { seq: 1 } },
{ upsert: true, returnDocument: 'after' }
)
})
})
describe('subscribe / unsubscribe', () => {
it('subscriber receives events for the correct device', async () => {
const service = ExchangeStoreService.getInstance()
const listener1 = vi.fn()
const listener2 = vi.fn()
service.subscribe('dev-A', listener1)
service.subscribe('dev-B', listener2)
await service.persistExchange({
deviceId: 'dev-A',
direction: 'CPE→ACS',
sessionId: 's1',
rawXml: '<cwmp:Inform/>',
})
expect(listener1).toHaveBeenCalledTimes(1)
expect(listener2).not.toHaveBeenCalled()
service.unsubscribe('dev-A', listener1)
service.unsubscribe('dev-B', listener2)
})
it('unsubscribed listener no longer receives events', async () => {
const service = ExchangeStoreService.getInstance()
const listener = vi.fn()
service.subscribe('dev-C', listener)
service.unsubscribe('dev-C', listener)
await service.persistExchange({
deviceId: 'dev-C',
direction: 'CPE→ACS',
sessionId: 's2',
rawXml: '<cwmp:Inform/>',
})
expect(listener).not.toHaveBeenCalled()
})
})
})