diff --git a/docs/api/tr069-exchanges.yaml b/docs/api/tr069-exchanges.yaml deleted file mode 100644 index 19be4df..0000000 --- a/docs/api/tr069-exchanges.yaml +++ /dev/null @@ -1,475 +0,0 @@ -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: '...' - - 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: '1' - 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: '...' - 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: - data: - - ``` - - 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":"..."} - - ``` - - ## 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":"..."} - - 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":"..."} - - '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: '...' - - 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 diff --git a/docs/deployment/tr069-exchange-viewer.md b/docs/deployment/tr069-exchange-viewer.md deleted file mode 100644 index c45971c..0000000 --- a/docs/deployment/tr069-exchange-viewer.md +++ /dev/null @@ -1,363 +0,0 @@ -# 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)). diff --git a/docs/design-parameter-writability.md b/docs/design-parameter-writability.md deleted file mode 100644 index cb2d6e0..0000000 --- a/docs/design-parameter-writability.md +++ /dev/null @@ -1,289 +0,0 @@ -# Design — Writabilité des paramètres TR-069 - -## Problème - -La méthode TR-069 `GetParameterNames` permet de découvrir les paramètres d'un équipement et de savoir lesquels sont accessibles en écriture (`writable=true`) ou en lecture seule (`writable=false`). - -Certains équipements — notamment les **Livebox Sagemcom** — ne répondent pas à cet appel ou retournent `Fault 9000 / Method not supported`. Sans cette information, l'ACS ne peut pas : - -- Afficher un indicateur R/W vs Read-only dans la fiche device -- Protéger l'utilisateur contre une tentative de `SetParameterValues` sur un paramètre en lecture seule - ---- - -## Contraintes architecturales (état actuel du codebase) - -1. **TR-069 est session-bound** : l'ACS ne peut pas appeler `GetParameterNames` à la demande depuis une requête HTTP. Le CPE initie les sessions. Les RPC CWMP sont exécutés dans la state machine (`DiscoveringParametersState` → `DeviceActionExecutor.getParameterNames`). - -2. **La writabilité est déjà persistée** : `device.parameters[]` = writable (issu de `organizeParameters()` après GPN réussi), `device.properties[]` = RO si GPN a réussi, mais par défaut tout param inconnu va aussi dans `properties[]` via `updateFromValues()` (ligne 201 de `device.ts`). - -3. **Le fault code GPN n'est pas tracé** : quand GPN retourne `Fault 9000`, `success = false` dans la state machine, mais rien n'indique que le device ne supporte pas GPN. On ne distingue pas "device hors-ligne" de "GPN non supporté". - ---- - -## Approches envisagées - -### Option A — Writabilité depuis session + catalogue statique (retenue) - -Exploiter la writabilité déjà apprise pendant les sessions CWMP, complétée par un fichier catalogue statique TR-181 pour les devices qui ne supportent pas GPN. - -**Principe :** -- Ajouter `gpnStatus: 'supported' | 'unsupported' | 'unknown'` au device -- Pendant la session : si GPN réussit → `gpnStatus = 'supported'` + `organizeParameters()` ; si `Fault 9000/9001` → `gpnStatus = 'unsupported'` ; si autre erreur → `gpnStatus` inchangé (erreur transitoire) -- L'API lit l'état persisté et applique la logique de résolution -- Pas de GPN live depuis l'API HTTP - -**Avantages :** cohérent avec l'architecture session-bound, sans état supplémentaire en mémoire, distingue les cas correctement. - ---- - -### Option B — GPN live synchrone depuis l'API HTTP - -Appeler GPN à la demande lors d'une requête HTTP avec un timeout court. - -*Non retenu — TR-069 est session-bound : impossible d'envoyer un RPC sans session active. Un timeout n'est pas équivalent à "non supporté".* - ---- - -### Option C — Cache long-terme par device (parameterMeta) - -Persister le résultat de GPN dans un champ dédié par session. - -*Non retenu — `parameters[]`/`properties[]` jouent déjà ce rôle. Ajouter un champ `gpnStatus` suffit.* - ---- - -## Solution retenue - -### Logique de résolution (côté API — lecture de l'état persisté) - -``` -gpnStatus = 'supported' - → parameters[path] trouvé → writable = true (source: 'live') - → properties[path] trouvé → writable = false (source: 'live') - -gpnStatus = 'unsupported' - → Tous les params sont dans properties[] par défaut (updateFromValues) - → Consulter tr181-catalog.json[path] - → Trouvé → writable = catalog value (source: 'catalog') - → Absent → writable = true (source: 'default', badge "non vérifié TR-181") - -gpnStatus = 'unknown' (device jamais connecté ou erreur transitoire) - → Consulter tr181-catalog.json[path] - → Trouvé → writable = catalog value (source: 'catalog') - → Absent → writable = null (source: 'unknown' — afficher "?") -``` - -### Conditions de fallback `gpnStatus = 'unsupported'` - -Uniquement si le device retourne explicitement : -- `Fault 9000` — Method not supported -- `Fault 9001` — Request denied - -**Tout le reste** (timeout réseau, Fault 9002/9003/9004, device hors-ligne) = erreur transitoire → `gpnStatus` inchangé. - ---- - -## Modèle de données - -### Champ `gpnStatus` sur `DeviceRecord` - -```typescript -// app/domain/device/device_record.ts -export interface DeviceRecord { - identifier?: string - deviceId: DeviceId - properties: PropertySnapshot[] - parameters: ParameterSnapshot[] - gpnStatus?: 'supported' | 'unsupported' | 'unknown' // nouveau - compliance?: DeviceComplianceInfo - provisioningCode?: string - updatedAt?: Date - createdAt?: Date -} -``` - -### Fichier `config/tr181-catalog.json` - -Fichier statique embarqué dans l'application, versionné dans Git. Chargé en mémoire au démarrage, indexé par path pour un lookup O(1). - -**Source :** XMLs officiels Broadband Forum ([github.com/BroadbandForum/data-model-template](https://github.com/BroadbandForum/data-model-template)). Un script `scripts/generate-tr181-catalog.ts` peut régénérer ce fichier. - -```json -[ - { "path": "Device.DeviceInfo.SoftwareVersion", "writable": false, "deviceClass": "Device" }, - { "path": "Device.DeviceInfo.HardwareVersion", "writable": false, "deviceClass": "Device" }, - { "path": "Device.DeviceInfo.SerialNumber", "writable": false, "deviceClass": "Device" }, - { "path": "Device.DeviceInfo.Manufacturer", "writable": false, "deviceClass": "Device" }, - { "path": "Device.DeviceInfo.ProvisioningCode", "writable": true, "deviceClass": "Device" }, - { "path": "Device.ManagementServer.URL", "writable": true, "deviceClass": "Device" }, - { "path": "Device.ManagementServer.Username", "writable": true, "deviceClass": "Device" }, - { "path": "Device.ManagementServer.Password", "writable": true, "deviceClass": "Device" }, - { "path": "Device.ManagementServer.PeriodicInformInterval", "writable": true, "deviceClass": "Device" }, - { "path": "Device.IP.Interface.{i}.IPv4Address.{i}.IPAddress", "writable": false, "deviceClass": "Device" }, - { "path": "InternetGatewayDevice.DeviceInfo.SoftwareVersion", "writable": false, "deviceClass": "IGD" }, - { "path": "InternetGatewayDevice.DeviceInfo.HardwareVersion", "writable": false, "deviceClass": "IGD" }, - { "path": "InternetGatewayDevice.ManagementServer.URL", "writable": true, "deviceClass": "IGD" }, - { "path": "InternetGatewayDevice.ManagementServer.PeriodicInformInterval","writable": true, "deviceClass": "IGD" } -] -``` - -> Les paramètres avec index générique (`{i}`) sont matchés par normalisation du path (remplacement des segments numériques par `{i}`). - ---- - -## Implémentation - -### 1. Domaine — ajout de `gpnStatus` sur `Device` - -```typescript -// app/domain/device/device.ts -export type GpnStatus = 'supported' | 'unsupported' | 'unknown' - -export class Device { - private _gpnStatus: GpnStatus = 'unknown' - - public get gpnStatus(): GpnStatus { return this._gpnStatus } - public setGpnStatus(status: GpnStatus): void { this._gpnStatus = status } - - public toSnapshot(): DeviceSnapshot { - return { - // ...existant... - gpnStatus: this._gpnStatus, - } - } -} -``` - -### 2. Session CWMP — capture du statut GPN dans `DeviceActionExecutor` - -```typescript -// app/services/cwmp/device_action_executor.ts -async getParameterNames(device, parameterName, callback): Promise { - const wrappedCallback = async (result: TaskResult) => { - if (result.status === ResultStatus.Success) { - device.organizeParameters(result.data, new Date()) - device.setGpnStatus('supported') - await this.persistDevice(device) - } else { - // Inspecter le fault code pour distinguer "non supporté" de "erreur transitoire" - const faultCode = result.data?.FaultCode ?? result.data?.faultCode - const isUnsupported = faultCode === 9000 || faultCode === 9001 - if (isUnsupported) { - device.setGpnStatus('unsupported') - await this.persistDevice(device) - } - // Autres erreurs (réseau, timeout, 9002...) → gpnStatus inchangé - } - return callback(result) - } - return this.taskQueue.addTask(device.deviceId, 'get_parameter_names', parameterName, wrappedCallback) -} -``` - -### 3. Persistance — `DeviceRecord` et `CwmpController.loadDevice` - -```typescript -// Lors du chargement : restituer gpnStatus -device.setGpnStatus(record.gpnStatus ?? 'unknown') - -// Lors de la persistance : inclure gpnStatus dans createOrUpdate -await DeviceModel.createOrUpdate({ - identifier: snapshot.identifier, - deviceId: snapshot.deviceId, - properties: snapshot.properties, - parameters: snapshot.parameters, - gpnStatus: snapshot.gpnStatus, // nouveau - updatedAt: new Date(), -}) -``` - -### 4. Service de résolution de writabilité (lecture seule, côté API) - -```typescript -// app/services/parameter_writability.ts -import tr181Catalog from '#config/tr181-catalog.json' assert { type: 'json' } - -const catalogIndex = new Map(tr181Catalog.map((e) => [e.path, e.writable])) - -function normalizePath(path: string): string { - return path.replace(/\.\d+\./g, '.{i}.').replace(/\.\d+$/, '.{i}') -} - -export type WritabilityResult = { - writable: boolean | null - source: 'live' | 'catalog' | 'default' | 'unknown' -} - -export function resolveWritability( - deviceSnapshot: DeviceSnapshot, - paramPath: string -): WritabilityResult { - const { gpnStatus, parameters, properties } = deviceSnapshot - - if (gpnStatus === 'supported') { - if (parameters.some((p) => p.name === paramPath)) return { writable: true, source: 'live' } - if (properties.some((p) => p.name === paramPath)) return { writable: false, source: 'live' } - } - - // gpnStatus = 'unsupported' ou 'unknown' → consulter le catalogue - const normalized = normalizePath(paramPath) - if (catalogIndex.has(normalized)) { - return { writable: catalogIndex.get(normalized)!, source: 'catalog' } - } - - // Pas dans le catalogue - if (gpnStatus === 'unsupported') return { writable: true, source: 'default' } - return { writable: null, source: 'unknown' } -} -``` - -### 5. API — enrichissement de la liste de paramètres - -`GET /api/v1/devices/:id/parameters` retourne chaque paramètre avec `writable` et `writabilitySource` : - -```json -{ - "name": "Device.ManagementServer.URL", - "value": "http://acs.example.com/", - "writable": true, - "writabilitySource": "live" -} -``` - -### 6. Frontend - -| Paramètre | Valeur | Writabilité | -|-----------|--------|-------------| -| Device.DeviceInfo.SoftwareVersion | 1.4.19 | 🔒 Read-only | -| Device.ManagementServer.URL | http://… | ✏️ R/W | -| Device.X_VENDOR_Feature | xyz | ✏️ R/W *(non vérifié TR-181)* | -| Device.Something (device hors-ligne, absent catalogue) | — | ❓ Inconnu | - -- **🔒 Read-only** (`writable=false`) : bouton "Modifier" désactivé -- **✏️ R/W** (`writable=true, source=live|catalog`) : bouton "Modifier" actif -- **✏️ R/W non vérifié** (`writable=true, source=default`) : bouton actif + badge orange "non référencé TR-181" -- **❓ Inconnu** (`writable=null`) : bouton désactivé — device jamais connecté ou en erreur transitoire - ---- - -## Priorités d'implémentation - -1. **`gpnStatus` dans `Device` + `DeviceRecord`** — champ additionnel minimal -2. **`DeviceActionExecutor.getParameterNames`** — capturer `Fault 9000/9001` et mettre à jour `gpnStatus` -3. **`CwmpController.loadDevice`** — restituer `gpnStatus` depuis la DB -4. **Fichier `config/tr181-catalog.json`** — constituer depuis les sources BBF -5. **Service `resolveWritability`** — lecture seule, O(1) -6. **API enrichie** — `writable` + `writabilitySource` dans la liste de paramètres -7. **UI** — indicateurs et désactivation du bouton "Modifier" sur les params RO - ---- - -## Risques - -| Risque | Mitigation | -|--------|------------| -| Fault code absent ou mal structuré dans `result.data` | Normaliser l'accès : `result.data?.FaultCode ?? result.data?.Detail?.Fault?.FaultCode` | -| `gpnStatus = 'unsupported'` permanent même si firmware mis à jour | Réinitialiser à `'unknown'` lors d'un Bootstrap/Factory Reset event | -| Paramètres `{i}` non normalisés correctement | Tests unitaires sur `normalizePath` | -| Nouvelle révision TR-181 non couverte | Script de régénération + versionner le fichier dans Git | diff --git a/docs/migration/tr069-exchange-viewer.md b/docs/migration/tr069-exchange-viewer.md deleted file mode 100644 index ec5465f..0000000 --- a/docs/migration/tr069-exchange-viewer.md +++ /dev/null @@ -1,592 +0,0 @@ -# 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 " - 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//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//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 -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 { - 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(/]/) - 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. diff --git a/docs/monitoring/alerting-rules.md b/docs/monitoring/alerting-rules.md deleted file mode 100644 index b9ac094..0000000 --- a/docs/monitoring/alerting-rules.md +++ /dev/null @@ -1,396 +0,0 @@ -# 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 | diff --git a/docs/tr06_sequence/DESIGN.md b/docs/tr06_sequence/DESIGN.md deleted file mode 100644 index 7a20289..0000000 --- a/docs/tr06_sequence/DESIGN.md +++ /dev/null @@ -1,180 +0,0 @@ ---- -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. \ No newline at end of file diff --git a/docs/tr06_sequence/code.html b/docs/tr06_sequence/code.html deleted file mode 100644 index 42814fd..0000000 --- a/docs/tr06_sequence/code.html +++ /dev/null @@ -1,367 +0,0 @@ - - - - - -TR-069 Protocol Analyzer - - - - - - - - - -
-
-TR-069 Protocol Analyzer - -
-
-
-filter_list -
-settings -help -
-
-
- - - -
- -
-
-
-Device Serial -TR069-NODE-550291-B -
-
-
-IP Address -192.168.100.42 -
-
-
-Connection Status -
- -CWMP Session Established -
-
-
-
-Session ID: 88241 - -
-
- -
-
- -
-
CPE
-
ACS
-
- -
-
- -
- -
-
-
-
-
-
-Inform -10:05:22.450 -
-
- -
- -
-
-
-
-
-
-InformResponse -10:05:22.612 -
-
- -
- -
- -
-
-
-
-
-
-GetParameterValues -10:05:23.002 -
-
- -
- -
-
-
-
-
-
-GetParameterValuesResponse -10:05:23.189 -
-
- -
- -
-
-
-
-
-
-SetParameterValues -10:05:23.441 -
-
- -
-
-
- -
-
-
-description -Message Detail -
-
- - -
-
-
-
-CWMP:GetParameterValues -ID: 1024_A -
- -
-
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
-  <soap:Header>
-    <cwmp:ID soap:mustUnderstand="1">1024_A</cwmp:ID>
-  </soap:Header>
-  <soap:Body>
-    <cwmp:GetParameterValues>
-      <ParameterNames soap:arrayType="xsd:string[3]">
-        <string>InternetGatewayDevice.DeviceInfo.HardwareVersion</string>
-        <string>InternetGatewayDevice.DeviceInfo.SoftwareVersion</string>
-        <string>InternetGatewayDevice.ManagementServer.ConnectionRequestURL</string>
-      </ParameterNames>
-    </cwmp:GetParameterValues>
-  </soap:Body>
-</soap:Envelope>
-
-
-

Analysis Summary

-
-

ACS is requesting device hardware and software versioning information to determine compatible firmware upgrade paths.

-
-
-
-MTU Size -1500 Bytes -
-
-Latency -42ms -
-
-
-
-
-
-
-
- - - \ No newline at end of file diff --git a/docs/tr06_sequence/screen.png b/docs/tr06_sequence/screen.png deleted file mode 100644 index 360712c..0000000 Binary files a/docs/tr06_sequence/screen.png and /dev/null differ diff --git a/docs/troubleshooting/tr069-exchange-viewer.md b/docs/troubleshooting/tr069-exchange-viewer.md deleted file mode 100644 index 45ffa5d..0000000 --- a/docs/troubleshooting/tr069-exchange-viewer.md +++ /dev/null @@ -1,486 +0,0 @@ -# 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: "" }) - -# Voir les derniers messages -db.cwmp_exchanges.find({ deviceId: "" }).sort({ timestamp: -1 }).limit(5).pretty() - -# Voir la séquence max (dernier message) -db.cwmp_exchange_counters.findOne({ _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 = ''; - 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:''})" - -# 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//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//exchanges/stream - -# Purger les échanges d'un device -curl -X DELETE http://localhost:8080/api/v1/tr069-trace//exchanges -``` diff --git a/references/tr069-state-machine-problems.md b/references/tr069-state-machine-problems.md deleted file mode 100644 index 6f871ca..0000000 --- a/references/tr069-state-machine-problems.md +++ /dev/null @@ -1,69 +0,0 @@ -# TR-069 CWMP State Machine — Problem Catalog - -## CRITIQUE-1: Race Condition task queue / state machine - -- File: `statemachinemanager.ts` (continue() + setupTaskQueueListeners()) -- Detail: `addTask()` in DeviceActionExecutor emits 'taskSent' synchronously, setting `lastTaskId` BEFORE the XML is returned to the CPE. When both the state machine and task queue serve tasks in the same session, `lastTaskId` can point to the wrong task, corrupting CPE response routing. -- Impact: Responses routed to wrong callback, silent data corruption, session hangs. - -## CRITIQUE-2: Multiple superfluous sessions on BOOTSTRAP - -- File: `cwmpcontroller.ts` (handleInform()) + `statemachinemanager.ts` (startStateMachine()) -- Detail: `enqueueImmediate(GPV)` in the controller + implicit ScheduleInform via SPV systematically generate 3 sessions instead of 1. -- Impact: 3x ACS load per bootstrap, unnecessary CPE wake-ups, slower onboarding. - -## CRITIQUE-3: IdleState does not check gpnStatus before sending GPN - -- File: `mainstatemachine.ts` (IdleState.handle()) -- Detail: A device with gpnStatus='unsupported' (Livebox, buggy CPEs) still receives a GPN every session, causing systematic Fault or CPE crash/reboot. -- Impact: CPE crash loops, Fault flooding, device never reaches operational state. - -## CRITIQUE-4: Silent safetyGuard in executeCurrentState() - -- File: `mainstatemachine.ts` (executeCurrentState()) -- Detail: The `while (safetyGuard++ < 15)` loop stops silently if the machine loops. No log of blocking state, no transition to an explicit error state. -- Impact: Sessions terminate with empty response, device not marked in error, monitoring blind spot. - -## MAJEUR-5: Multi-event routing based on LAST event only - -- File: `statemachinemanager.ts` (startStateMachine()) -- Code: `const lastEvent = this.normalizeEvent(evts[evts.length - 1])` -- Detail: An Inform with ['0 BOOTSTRAP', '1 BOOT'] processes only '1 BOOT'. An Inform with ['6 CONNECTION REQUEST', '2 PERIODIC'] forces ImmediateCommandStateMachine when onboarding is required. -- Impact: Wrong state machine selected, missed onboarding, incorrect provisioning flow. - -## MAJEUR-6: Incomplete GPN fault codes in DeviceActionExecutor - -- File: `deviceactionexecutor.ts` (getParameterNames() wrappedCallback) -- Code: `if (code === 9000 || code === 9001) { device.setGpnStatus('unsupported') }` -- Detail: Codes 9003 (Invalid Arguments), 9004 (Resources Exceeded), 9005 (Invalid Parameter Name) should also trigger fallback. Network/timeout codes should NOT mark unsupported. -- Impact: Missing fallback on legitimate GPN failures, false unsupported marking on transient errors. - -## MAJEUR-7: No GPN circuit breaker across sessions - -- Files: `mainstatemachine.ts`, `configurationevaluationstate.ts`, `deviceactionexecutor.ts` -- Detail: No failure counter persisted between sessions. A CPE that crashes on GPN will be retried every Inform. No cooldown, no crash pattern detection. -- Impact: Infinite crash loops for vulnerable CPEs, ACS resource waste. - -## MAJEUR-8: Critical GPV outside state machine (collision with ConfigEvalState) - -- File: `cwmpcontroller.ts` (handleInform() enqueueImmediate block) -- Detail: GPV (HardwareVersion, SoftwareVersion, ConnectionRequestURL) is served by the task queue, not the state machine. It can interleave with GPN/GPV from ConfigurationEvaluationState, corrupting CWMP exchange order. -- Impact: Out-of-order CWMP messages, session corruption, device misidentification. - -## MINEUR-9: Duplicated EventCode normalization - -- Files: `cwmpcontroller.ts` (normalizeEventCode()), `statemachinemanager.ts` (normalizeEvent()) -- Detail: Two identical functions, two maintenance points. -- Impact: Drift risk, inconsistent normalization. - -## MINEUR-10: reclassifyDeviceFromCatalog() called at 4 different places - -- Files: `cwmpcontroller.ts`, `deviceactionexecutor.ts` (x2), `mainstatemachine.ts` (CompletedState) -- Detail: Scattered fallback logic, hard to unit test. -- Impact: Inconsistent reclassification timing, test complexity. - -## MINEUR-11: No explicit PartialOperational state - -- File: `configurationevaluationstate.ts` (catch block -> { isComplete: true }) -- Detail: On ConfigEval failure, the machine terminates without persisting a degraded status usable for monitoring/alerting. -- Impact: Monitoring blind to degraded devices, no alerting on partial provisioning. diff --git a/tests/unit/protocol/soap.spec.ts b/tests/unit/protocol/soap.spec.ts index 5598edd..ed26571 100644 --- a/tests/unit/protocol/soap.spec.ts +++ b/tests/unit/protocol/soap.spec.ts @@ -75,3 +75,49 @@ describe('buildSoapEnvelope', () => { expect(xml).toContain('') }) }) + +describe('extractCwmpMessage — SOAP Fault detection', () => { + it('detects soap-env:Fault as Fault message', () => { + const body = { + 'soap-env:Fault': { + faultcode: 'Client', + faultstring: 'CWMP fault', + detail: { 'cwmp:Fault': { FaultCode: '9001', FaultString: 'Request denied' } }, + }, + } + const msg = extractCwmpMessage(body) + expect(msg).not.toBeNull() + expect(msg!.type).toBe('Fault') + expect(msg!.data.faultcode).toBe('Client') + }) + + it('detects SOAP-ENV:Fault as Fault message', () => { + const body = { 'SOAP-ENV:Fault': { faultcode: 'Server', faultstring: 'Internal error' } } + const msg = extractCwmpMessage(body) + expect(msg).not.toBeNull() + expect(msg!.type).toBe('Fault') + }) + + it('detects bare Fault as Fault message', () => { + const body = { Fault: { FaultCode: '9002', FaultString: 'Internal error' } } + const msg = extractCwmpMessage(body) + expect(msg!.type).toBe('Fault') + }) + + it('detects soapenv:Fault as Fault message', () => { + const body = { 'soapenv:Fault': { faultcode: 'Client', faultstring: 'Fault' } } + const msg = extractCwmpMessage(body) + expect(msg!.type).toBe('Fault') + }) + + it('prefers cwmp: prefixed keys over Fault detection', () => { + const body = { 'cwmp:GetParameterValuesResponse': { ParameterList: [] }, 'soap-env:Fault': { faultcode: 'Client' } } + const msg = extractCwmpMessage(body) + expect(msg!.type).toBe('GetParameterValuesResponse') + }) + + it('returns null when body has no cwmp: keys and no Fault', () => { + expect(extractCwmpMessage({ 'other:Something': {} })).toBeNull() + }) +}) + diff --git a/tests/unit/protocol/soap_fault.spec.ts b/tests/unit/protocol/soap_fault.spec.ts deleted file mode 100644 index d5ed4c2..0000000 --- a/tests/unit/protocol/soap_fault.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { extractCwmpMessage } from '../../../app/protocol/soap.js' - -describe('extractCwmpMessage — SOAP Fault detection', () => { - it('detects soap-env:Fault as Fault message', () => { - const body = { - 'soap-env:Fault': { - faultcode: 'Client', - faultstring: 'CWMP fault', - detail: { 'cwmp:Fault': { FaultCode: '9001', FaultString: 'Request denied' } }, - }, - } - const msg = extractCwmpMessage(body) - expect(msg).not.toBeNull() - expect(msg!.type).toBe('Fault') - expect(msg!.data.faultcode).toBe('Client') - }) - - it('detects SOAP-ENV:Fault as Fault message', () => { - const body = { - 'SOAP-ENV:Fault': { - faultcode: 'Server', - faultstring: 'Internal error', - }, - } - const msg = extractCwmpMessage(body) - expect(msg).not.toBeNull() - expect(msg!.type).toBe('Fault') - expect(msg!.data.faultcode).toBe('Server') - }) - - it('detects bare Fault as Fault message', () => { - const body = { - Fault: { FaultCode: '9002', FaultString: 'Internal error' }, - } - const msg = extractCwmpMessage(body) - expect(msg).not.toBeNull() - expect(msg!.type).toBe('Fault') - }) - - it('detects soapenv:Fault as Fault message', () => { - const body = { - 'soapenv:Fault': { faultcode: 'Client', faultstring: 'Fault' }, - } - const msg = extractCwmpMessage(body) - expect(msg).not.toBeNull() - expect(msg!.type).toBe('Fault') - }) - - it('prefers cwmp: prefixed keys over Fault detection', () => { - const body = { - 'cwmp:GetParameterValuesResponse': { ParameterList: [] }, - 'soap-env:Fault': { faultcode: 'Client' }, - } - const msg = extractCwmpMessage(body) - expect(msg).not.toBeNull() - expect(msg!.type).toBe('GetParameterValuesResponse') - }) - - it('returns null when body has no cwmp: keys and no Fault', () => { - const body = { 'other:Something': {} } - expect(extractCwmpMessage(body)).toBeNull() - }) -}) diff --git a/tests/unit/services/configuration_engine.spec.ts b/tests/unit/services/configuration_engine.spec.ts index 8719546..b047525 100644 --- a/tests/unit/services/configuration_engine.spec.ts +++ b/tests/unit/services/configuration_engine.spec.ts @@ -1,175 +1,253 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import ConfigurationEngine from '../../../app/services/configuration_engine.js' +import { describe, it, expect, vi, beforeEach } from 'vitest' +// Mock logger +vi.mock('@adonisjs/core/services/logger', () => ({ + default: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +// Mock DeviceModel +vi.mock('../../../app/models/device.js', () => ({ + default: { + createOrUpdate: vi.fn().mockResolvedValue({}), + findByDeviceId: vi.fn().mockResolvedValue(null), + findOne: vi.fn().mockResolvedValue(null), + updateOne: vi.fn().mockResolvedValue({}), + }, +})) + +// Mock ConfigurationModel — dynamically controlled per test vi.mock('#models/configuration', () => ({ - default: { findOne: vi.fn(), findAllEnabled: vi.fn() }, + default: { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn().mockResolvedValue(null), + }, })) -vi.mock('#models/device', () => ({ - default: { getCollection: vi.fn(), findOne: vi.fn() }, +// Mock FirmwareCatalogService +vi.mock('../../../app/services/firmware_catalog_service.js', () => ({ + default: class { async resolveUpgradePath() { return [] } }, + FirmwareCatalogService: class { async resolveUpgradePath() { return [] } }, })) +// Mock schedule_window +vi.mock('../../../app/utils/schedule_window.js', () => ({ + isWithinSchedule: vi.fn().mockReturnValue(true), +})) + +import ConfigurationEngine from '../../../app/services/configuration_engine.js' +import { Device } from '../../../app/domain/device/device.js' +import { DeviceId } from '../../../app/domain/device/device_id.js' +import { DeviceProperty, DeviceParameter } from '../../../app/domain/device/device_parameter.js' +import { ParameterType } from '../../../app/domain/shared/types.js' import ConfigurationModel from '#models/configuration' -import DeviceModel from '#models/device' -describe('ConfigurationEngine', () => { +function makeDevice(opts: { manufacturer?: string; productClass?: string; provisioningCode?: string } = {}): Device { + const device = new Device(new DeviceId( + opts.manufacturer || 'Sagemcom', + 'F8B7FF', + opts.productClass || 'Livebox 4', + 'LB4-TEST-001' + )) + device.setDataModel('tr-181') + if (opts.provisioningCode) { + (device as any).provisioningCode = opts.provisioningCode + } + return device +} + +function makeConfig(overrides: any = {}) { + return { + identifier: 'test-config', + name: 'Test Config', + version: '1.0.0', + priority: 1, + policy: 'maintain', + enabled: true, + criteria: { manufacturer: 'Sagemcom', productClass: 'Livebox 4' }, + inventory: [{ path: 'Device.ManagementServer.' }], + desiredParameters: [ + { name: 'Device.ManagementServer.PeriodicInformInterval', value: '300', type: 'unsignedInt' }, + ], + firmwareTarget: null, + schedule: null, + tags: [], + ...overrides, + } +} + +describe('ConfigurationEngine — coverage', () => { let engine: ConfigurationEngine beforeEach(() => { - engine = new ConfigurationEngine() vi.clearAllMocks() + engine = new ConfigurationEngine() }) - describe('complianceReport', () => { - it('throws when configuration is not found', async () => { - vi.mocked(ConfigurationModel.findOne).mockResolvedValue(null as any) + // --- evaluate() --- - await expect(engine.complianceReport('missing-config')).rejects.toThrow( - 'Configuration missing-config not found' - ) - }) - - it('returns an empty report when no devices match', async () => { - vi.mocked(ConfigurationModel.findOne).mockResolvedValue({ - identifier: 'config-1', - name: 'Config 1', - criteria: {}, - inventory: [], - desiredParameters: [], - policy: 'maintain', - } as any) - vi.mocked(DeviceModel.getCollection).mockReturnValue({ - find: () => ({ toArray: async () => [] }), - } as any) - - const report = await engine.complianceReport('config-1') - - expect(report).toMatchObject({ - configurationId: 'config-1', - configurationName: 'Config 1', - totalDevices: 0, - compliantCount: 0, - nonCompliantCount: 0, - devices: [], - }) - }) - - it('counts compliant and non-compliant devices', async () => { - const config = { - identifier: 'config-1', - name: 'Config 1', - criteria: {}, - inventory: [], - desiredParameters: [], - policy: 'maintain', - } - const devices = [ - { identifier: 'device-1', deviceId: { serialNumber: 'SN1' } }, - { identifier: 'device-2', deviceId: { serialNumber: 'SN2' } }, - ] - vi.mocked(ConfigurationModel.findOne).mockResolvedValue(config as any) - vi.mocked(DeviceModel.getCollection).mockReturnValue({ - find: () => ({ toArray: async () => devices }), - } as any) - vi.spyOn(engine, 'evaluateSpecific') - .mockResolvedValueOnce({ - configuration: config as any, - inventoryPaths: [], - parameterDiffs: [], - firmwareMigrationPath: [], - isAlreadyCompliant: true, - skipFirmware: false, - }) - .mockResolvedValueOnce({ - configuration: config as any, - inventoryPaths: [], - parameterDiffs: [ - { - name: 'Device.ManagementServer.URL', - currentValue: 'http://old', - targetValue: 'http://acs', - type: 'string', - }, - ], - firmwareMigrationPath: [], - isAlreadyCompliant: false, - skipFirmware: false, - }) - - const report = await engine.complianceReport('config-1') - - expect(report.totalDevices).toBe(2) - expect(report.compliantCount).toBe(1) - expect(report.nonCompliantCount).toBe(1) - expect(report.devices.map((device) => device.compliant)).toEqual([true, false]) - }) - - it('skips null parameterCriteria names when matching devices', async () => { - const config = { - identifier: 'config-1', - name: 'Config 1', - criteria: { parameterCriteria: [{ name: null as any, value: 'x' }] }, - inventory: [], - desiredParameters: [], - policy: 'maintain', - } - const device = { identifier: 'device-1', deviceId: { serialNumber: 'SN1' } } - vi.mocked(ConfigurationModel.findOne).mockResolvedValue(config as any) - vi.mocked(DeviceModel.getCollection).mockReturnValue({ - find: () => ({ toArray: async () => [device] }), - } as any) - vi.spyOn(engine, 'evaluateSpecific').mockResolvedValue({ - configuration: config as any, - inventoryPaths: [], - parameterDiffs: [], - firmwareMigrationPath: [], - isAlreadyCompliant: true, - skipFirmware: false, - }) - - const report = await engine.complianceReport('config-1') - - expect(report.totalDevices).toBe(1) - expect(report.compliantCount).toBe(1) - expect(engine.evaluateSpecific).toHaveBeenCalledWith(device, config) - }) + it('returns isAlreadyCompliant when no configs exist', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([]) + const device = makeDevice() + const result = await engine.evaluate(device) + expect(result.isAlreadyCompliant).toBe(true) + expect(result.configuration).toBeNull() }) - describe('evaluateSpecific', () => { - it('falls back to plain object parameters when getParameter is unavailable', async () => { - const device = { - identifier: 'device-1', - deviceId: { serialNumber: 'SN1' }, - parameters: [ - { - name: 'Device.ManagementServer.URL', - value: 'http://acs', - }, - ], - } - const config = { - identifier: 'config-1', - name: 'Config 1', - version: '1.0.0', - priority: 100, - policy: 'maintain', - enabled: true, - tags: [], - criteria: {}, - inventory: [], - desiredParameters: [ - { - name: 'Device.ManagementServer.URL', - value: 'http://acs', - type: 'string', - }, - ], - } + it('returns isAlreadyCompliant when no config matches criteria', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ criteria: { manufacturer: 'Huawei' } })]) + const device = makeDevice() + const result = await engine.evaluate(device) + expect(result.isAlreadyCompliant).toBe(true) + expect(result.configuration).toBeNull() + }) - const result = await engine.evaluateSpecific(device as any, config as any) + it('selects highest priority config when multiple match', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([ + makeConfig({ identifier: 'low', priority: 1 }), + makeConfig({ identifier: 'high', priority: 10 }), + makeConfig({ identifier: 'mid', priority: 5 }), + ]) + const device = makeDevice() + const result = await engine.evaluate(device) + expect(result.configuration?.identifier).toBe('high') + }) - expect(result.isAlreadyCompliant).toBe(true) - expect(result.parameterDiffs).toEqual([]) - }) + it('returns parameterDiffs when device value differs from desired', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig()]) + const device = makeDevice() + // Set a different value + device.setParameter(new DeviceParameter( + 'Device.ManagementServer.PeriodicInformInterval', + '60', + ParameterType.UNSIGNED_INT + )) + const result = await engine.evaluate(device) + expect(result.parameterDiffs.length).toBe(1) + expect(result.parameterDiffs[0].currentValue).toBe('60') + expect(result.parameterDiffs[0].targetValue).toBe('300') + expect(result.isAlreadyCompliant).toBe(false) + }) + + it('returns empty diffs when device value matches desired', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig()]) + const device = makeDevice() + device.setParameter(new DeviceParameter( + 'Device.ManagementServer.PeriodicInformInterval', + '300', + ParameterType.UNSIGNED_INT + )) + const result = await engine.evaluate(device) + expect(result.parameterDiffs.length).toBe(0) + expect(result.isAlreadyCompliant).toBe(true) + }) + + it('treats null current value as diff', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig()]) + const device = makeDevice() + // Don't set the parameter — it's unknown + const result = await engine.evaluate(device) + expect(result.parameterDiffs.length).toBe(1) + expect(result.parameterDiffs[0].currentValue).toBeNull() + }) + + // --- matchesCriteria --- + + it('matchesCriteria matches glob patterns on manufacturer', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ criteria: { manufacturer: 'Sage*' } })]) + const device = makeDevice() + const result = await engine.evaluate(device) + expect(result.configuration).not.toBeNull() + }) + + it('matchesCriteria matches wildcard * on productClass', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ criteria: { manufacturer: 'Sagemcom', productClass: '*' } })]) + const device = makeDevice() + const result = await engine.evaluate(device) + expect(result.configuration).not.toBeNull() + }) + + it('matchesCriteria rejects when parameterCriteria value mismatches', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ + criteria: { + manufacturer: 'Sagemcom', + parameterCriteria: [{ name: 'Device.DeviceInfo.ModelName', value: 'WrongModel' }], + }, + })]) + const device = makeDevice() + device.setParameter(new DeviceParameter('Device.DeviceInfo.ModelName', 'Livebox 4', ParameterType.STRING)) + const result = await engine.evaluate(device) + expect(result.configuration).toBeNull() + expect(result.isAlreadyCompliant).toBe(true) + }) + + it('matchesCriteria rejects when parameterCriteria param is missing on device', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ + criteria: { + manufacturer: 'Sagemcom', + parameterCriteria: [{ name: 'Device.DeviceInfo.ModelName', value: 'Livebox 4' }], + }, + })]) + const device = makeDevice() + // Don't set ModelName → param is missing + const result = await engine.evaluate(device) + expect(result.configuration).toBeNull() + }) + + // --- shouldApplyOnce (policy: once) --- + + it('shouldApplyOnce returns true (re-apply) when no provisioningCode on device', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ policy: 'once' })]) + const device = makeDevice() + const result = await engine.evaluate(device) + // Should evaluate normally (not skip) + expect(result.configuration).not.toBeNull() + }) + + it('shouldApplyOnce returns false (skip) when provisioningCode hash matches', async () => { + const config = makeConfig({ policy: 'once' }) + vi.mocked(ConfigurationModel.find).mockResolvedValue([config]) + + const device = makeDevice() + // Compute the expected provisioning code + const { buildProvisioningCode } = await import('../../../app/utils/provisioning_code.js') + const expectedPC = buildProvisioningCode(config as any) + ;(device as any).provisioningCode = expectedPC + + const result = await engine.evaluate(device) + expect(result.isAlreadyCompliant).toBe(true) + expect(result.parameterDiffs.length).toBe(0) + }) + + // --- Numeric and boolean comparison --- + + it('computeParameterDiffs handles numeric comparison (unsignedInt)', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ + desiredParameters: [{ name: 'Device.X.Interval', value: '300', type: 'unsignedInt' }], + })]) + const device = makeDevice() + device.setParameter(new DeviceParameter('Device.X.Interval', '300', ParameterType.UNSIGNED_INT)) + const result = await engine.evaluate(device) + expect(result.parameterDiffs.length).toBe(0) // 300 == 300 + }) + + it('computeParameterDiffs handles boolean comparison', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ + desiredParameters: [{ name: 'Device.X.Enabled', value: 'true', type: 'boolean' }], + })]) + const device = makeDevice() + device.setParameter(new DeviceParameter('Device.X.Enabled', '1', ParameterType.BOOLEAN)) + const result = await engine.evaluate(device) + // '1' and 'true' should be treated as same boolean + expect(result.parameterDiffs.length).toBe(0) + }) + + it('computeParameterDiffs detects boolean mismatch', async () => { + vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ + desiredParameters: [{ name: 'Device.X.Enabled', value: 'true', type: 'boolean' }], + })]) + const device = makeDevice() + device.setParameter(new DeviceParameter('Device.X.Enabled', 'false', ParameterType.BOOLEAN)) + const result = await engine.evaluate(device) + expect(result.parameterDiffs.length).toBe(1) }) }) diff --git a/tests/unit/services/configuration_engine_coverage.spec.ts b/tests/unit/services/configuration_engine_coverage.spec.ts deleted file mode 100644 index b047525..0000000 --- a/tests/unit/services/configuration_engine_coverage.spec.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// Mock logger -vi.mock('@adonisjs/core/services/logger', () => ({ - default: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, -})) - -// Mock DeviceModel -vi.mock('../../../app/models/device.js', () => ({ - default: { - createOrUpdate: vi.fn().mockResolvedValue({}), - findByDeviceId: vi.fn().mockResolvedValue(null), - findOne: vi.fn().mockResolvedValue(null), - updateOne: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock ConfigurationModel — dynamically controlled per test -vi.mock('#models/configuration', () => ({ - default: { - find: vi.fn().mockResolvedValue([]), - findOne: vi.fn().mockResolvedValue(null), - }, -})) - -// Mock FirmwareCatalogService -vi.mock('../../../app/services/firmware_catalog_service.js', () => ({ - default: class { async resolveUpgradePath() { return [] } }, - FirmwareCatalogService: class { async resolveUpgradePath() { return [] } }, -})) - -// Mock schedule_window -vi.mock('../../../app/utils/schedule_window.js', () => ({ - isWithinSchedule: vi.fn().mockReturnValue(true), -})) - -import ConfigurationEngine from '../../../app/services/configuration_engine.js' -import { Device } from '../../../app/domain/device/device.js' -import { DeviceId } from '../../../app/domain/device/device_id.js' -import { DeviceProperty, DeviceParameter } from '../../../app/domain/device/device_parameter.js' -import { ParameterType } from '../../../app/domain/shared/types.js' -import ConfigurationModel from '#models/configuration' - -function makeDevice(opts: { manufacturer?: string; productClass?: string; provisioningCode?: string } = {}): Device { - const device = new Device(new DeviceId( - opts.manufacturer || 'Sagemcom', - 'F8B7FF', - opts.productClass || 'Livebox 4', - 'LB4-TEST-001' - )) - device.setDataModel('tr-181') - if (opts.provisioningCode) { - (device as any).provisioningCode = opts.provisioningCode - } - return device -} - -function makeConfig(overrides: any = {}) { - return { - identifier: 'test-config', - name: 'Test Config', - version: '1.0.0', - priority: 1, - policy: 'maintain', - enabled: true, - criteria: { manufacturer: 'Sagemcom', productClass: 'Livebox 4' }, - inventory: [{ path: 'Device.ManagementServer.' }], - desiredParameters: [ - { name: 'Device.ManagementServer.PeriodicInformInterval', value: '300', type: 'unsignedInt' }, - ], - firmwareTarget: null, - schedule: null, - tags: [], - ...overrides, - } -} - -describe('ConfigurationEngine — coverage', () => { - let engine: ConfigurationEngine - - beforeEach(() => { - vi.clearAllMocks() - engine = new ConfigurationEngine() - }) - - // --- evaluate() --- - - it('returns isAlreadyCompliant when no configs exist', async () => { - vi.mocked(ConfigurationModel.find).mockResolvedValue([]) - const device = makeDevice() - const result = await engine.evaluate(device) - expect(result.isAlreadyCompliant).toBe(true) - expect(result.configuration).toBeNull() - }) - - it('returns isAlreadyCompliant when no config matches criteria', async () => { - vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ criteria: { manufacturer: 'Huawei' } })]) - const device = makeDevice() - const result = await engine.evaluate(device) - expect(result.isAlreadyCompliant).toBe(true) - expect(result.configuration).toBeNull() - }) - - it('selects highest priority config when multiple match', async () => { - vi.mocked(ConfigurationModel.find).mockResolvedValue([ - makeConfig({ identifier: 'low', priority: 1 }), - makeConfig({ identifier: 'high', priority: 10 }), - makeConfig({ identifier: 'mid', priority: 5 }), - ]) - const device = makeDevice() - const result = await engine.evaluate(device) - expect(result.configuration?.identifier).toBe('high') - }) - - it('returns parameterDiffs when device value differs from desired', async () => { - vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig()]) - const device = makeDevice() - // Set a different value - device.setParameter(new DeviceParameter( - 'Device.ManagementServer.PeriodicInformInterval', - '60', - ParameterType.UNSIGNED_INT - )) - const result = await engine.evaluate(device) - expect(result.parameterDiffs.length).toBe(1) - expect(result.parameterDiffs[0].currentValue).toBe('60') - expect(result.parameterDiffs[0].targetValue).toBe('300') - expect(result.isAlreadyCompliant).toBe(false) - }) - - it('returns empty diffs when device value matches desired', async () => { - vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig()]) - const device = makeDevice() - device.setParameter(new DeviceParameter( - 'Device.ManagementServer.PeriodicInformInterval', - '300', - ParameterType.UNSIGNED_INT - )) - const result = await engine.evaluate(device) - expect(result.parameterDiffs.length).toBe(0) - expect(result.isAlreadyCompliant).toBe(true) - }) - - it('treats null current value as diff', async () => { - vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig()]) - const device = makeDevice() - // Don't set the parameter — it's unknown - const result = await engine.evaluate(device) - expect(result.parameterDiffs.length).toBe(1) - expect(result.parameterDiffs[0].currentValue).toBeNull() - }) - - // --- matchesCriteria --- - - it('matchesCriteria matches glob patterns on manufacturer', async () => { - vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ criteria: { manufacturer: 'Sage*' } })]) - const device = makeDevice() - const result = await engine.evaluate(device) - expect(result.configuration).not.toBeNull() - }) - - it('matchesCriteria matches wildcard * on productClass', async () => { - vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ criteria: { manufacturer: 'Sagemcom', productClass: '*' } })]) - const device = makeDevice() - const result = await engine.evaluate(device) - expect(result.configuration).not.toBeNull() - }) - - it('matchesCriteria rejects when parameterCriteria value mismatches', async () => { - vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ - criteria: { - manufacturer: 'Sagemcom', - parameterCriteria: [{ name: 'Device.DeviceInfo.ModelName', value: 'WrongModel' }], - }, - })]) - const device = makeDevice() - device.setParameter(new DeviceParameter('Device.DeviceInfo.ModelName', 'Livebox 4', ParameterType.STRING)) - const result = await engine.evaluate(device) - expect(result.configuration).toBeNull() - expect(result.isAlreadyCompliant).toBe(true) - }) - - it('matchesCriteria rejects when parameterCriteria param is missing on device', async () => { - vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ - criteria: { - manufacturer: 'Sagemcom', - parameterCriteria: [{ name: 'Device.DeviceInfo.ModelName', value: 'Livebox 4' }], - }, - })]) - const device = makeDevice() - // Don't set ModelName → param is missing - const result = await engine.evaluate(device) - expect(result.configuration).toBeNull() - }) - - // --- shouldApplyOnce (policy: once) --- - - it('shouldApplyOnce returns true (re-apply) when no provisioningCode on device', async () => { - vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ policy: 'once' })]) - const device = makeDevice() - const result = await engine.evaluate(device) - // Should evaluate normally (not skip) - expect(result.configuration).not.toBeNull() - }) - - it('shouldApplyOnce returns false (skip) when provisioningCode hash matches', async () => { - const config = makeConfig({ policy: 'once' }) - vi.mocked(ConfigurationModel.find).mockResolvedValue([config]) - - const device = makeDevice() - // Compute the expected provisioning code - const { buildProvisioningCode } = await import('../../../app/utils/provisioning_code.js') - const expectedPC = buildProvisioningCode(config as any) - ;(device as any).provisioningCode = expectedPC - - const result = await engine.evaluate(device) - expect(result.isAlreadyCompliant).toBe(true) - expect(result.parameterDiffs.length).toBe(0) - }) - - // --- Numeric and boolean comparison --- - - it('computeParameterDiffs handles numeric comparison (unsignedInt)', async () => { - vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ - desiredParameters: [{ name: 'Device.X.Interval', value: '300', type: 'unsignedInt' }], - })]) - const device = makeDevice() - device.setParameter(new DeviceParameter('Device.X.Interval', '300', ParameterType.UNSIGNED_INT)) - const result = await engine.evaluate(device) - expect(result.parameterDiffs.length).toBe(0) // 300 == 300 - }) - - it('computeParameterDiffs handles boolean comparison', async () => { - vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ - desiredParameters: [{ name: 'Device.X.Enabled', value: 'true', type: 'boolean' }], - })]) - const device = makeDevice() - device.setParameter(new DeviceParameter('Device.X.Enabled', '1', ParameterType.BOOLEAN)) - const result = await engine.evaluate(device) - // '1' and 'true' should be treated as same boolean - expect(result.parameterDiffs.length).toBe(0) - }) - - it('computeParameterDiffs detects boolean mismatch', async () => { - vi.mocked(ConfigurationModel.find).mockResolvedValue([makeConfig({ - desiredParameters: [{ name: 'Device.X.Enabled', value: 'true', type: 'boolean' }], - })]) - const device = makeDevice() - device.setParameter(new DeviceParameter('Device.X.Enabled', 'false', ParameterType.BOOLEAN)) - const result = await engine.evaluate(device) - expect(result.parameterDiffs.length).toBe(1) - }) -}) diff --git a/tests/unit/services/cwmp/config_eval_skip.spec.ts b/tests/unit/services/cwmp/config_eval_skip.spec.ts deleted file mode 100644 index ad2f022..0000000 --- a/tests/unit/services/cwmp/config_eval_skip.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// Mock logger -vi.mock('@adonisjs/core/services/logger', () => ({ - default: { - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})) - -// Mock DeviceModel -vi.mock('../../../../app/models/device.js', () => ({ - default: { - createOrUpdate: vi.fn().mockResolvedValue({}), - findByDeviceId: vi.fn().mockResolvedValue(null), - updateOne: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock CommandModel -vi.mock('../../../../app/models/command.js', () => ({ - default: { - getCollection: vi.fn().mockReturnValue({ - find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), - countDocuments: vi.fn().mockResolvedValue(0), - }), - updateCommand: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock ConfigurationModel -vi.mock('#models/configuration', () => ({ - default: { - find: vi.fn().mockResolvedValue([]), - findByIdentifier: vi.fn().mockResolvedValue(null), - }, -})) - -// Mock firmware catalog service -vi.mock('../../../../app/services/firmware_catalog_service.js', () => ({ - default: class { - async resolveUpgradePath() { return [] } - }, - FirmwareCatalogService: class { - async resolveUpgradePath() { return [] } - }, -})) - -// Mock parameter_writability -vi.mock('../../../../app/services/parameter_writability.js', () => ({ - reclassifyDeviceFromCatalog: vi.fn(), -})) - -import { ConfigurationEvaluationState, ComplianceUpdateState } from '../../../../app/services/cwmp/configuration_evaluation_state.js' -import { Device } from '../../../../app/domain/device/device.js' -import { DeviceId } from '../../../../app/domain/device/device_id.js' -import { DeviceProperty } from '../../../../app/domain/device/device_parameter.js' -import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js' -import { DeviceActionExecutor } from '../../../../app/services/cwmp/device_action_executor.js' -import { ParameterType } from '../../../../app/domain/shared/types.js' -import { buildProvisioningCode } from '../../../../app/utils/provisioning_code.js' - -function makeDevice(): Device { - const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) - device.setDataModel('tr-181') - device.setGpnStatus('unsupported') - return device -} - -function makeContext(device: Device) { - const taskQueue = new CWMPTaskQueue() - return { - device, - session: 'test-session', - taskQueue, - services: { - deviceAction: new DeviceActionExecutor(taskQueue), - }, - } -} - -describe('ConfigurationEvaluationState — skip inventory when already compliant', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('skips inventory when isAlreadyCompliant is true (no matching config)', async () => { - const device = makeDevice() - const context = makeContext(device) - - const state = new ConfigurationEvaluationState() - const result = await state.handle(context as any) - - // With no configs in DB, evaluate returns isAlreadyCompliant: true - // State should transition forward without sending any GPN/GPV - // (no inventory, no diffs) - expect(result.response).toBeFalsy() - expect(result.nextState || result.isComplete).toBeTruthy() - }) -}) - -describe('ComplianceUpdateState — skip provCode SPV when matching', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('skips SPV when device already has matching provisioning code', async () => { - const device = makeDevice() - - const mockConfig = { identifier: 'TestConfig', version: '1.0', desiredParameters: [], firmwareTarget: null } - const expectedProvCode = buildProvisioningCode(mockConfig as any) - - // Set the device's ProvisioningCode to match what buildProvisioningCode produces - device.setProperty(new DeviceProperty( - 'Device.DeviceInfo.ProvisioningCode', - expectedProvCode, - ParameterType.STRING - )) - - const context = makeContext(device) - - const mockEngineResult = { - configuration: mockConfig, - inventoryPaths: [], - parameterDiffs: [], - firmwareMigrationPath: [], - isAlreadyCompliant: true, - skipFirmware: false, - } - - const state = new ComplianceUpdateState(mockEngineResult as any) - const result = await state.handle(context as any) - - // Should NOT produce a response (no SPV sent) - // Should transition to ExecutingCommandsState - expect(result.response).toBeFalsy() - expect(result.nextState).toBeDefined() - }) -}) diff --git a/tests/unit/services/cwmp/config_eval_extra.spec.ts b/tests/unit/services/cwmp/configuration_evaluation_state.integration.spec.ts similarity index 99% rename from tests/unit/services/cwmp/config_eval_extra.spec.ts rename to tests/unit/services/cwmp/configuration_evaluation_state.integration.spec.ts index 4ac27c1..cd46765 100644 --- a/tests/unit/services/cwmp/config_eval_extra.spec.ts +++ b/tests/unit/services/cwmp/configuration_evaluation_state.integration.spec.ts @@ -597,3 +597,5 @@ describe('ConfigurationEvaluationState — firmware upgrade transition (lines 15 }) }) + + diff --git a/tests/unit/services/cwmp/critical_reads_response.spec.ts b/tests/unit/services/cwmp/critical_reads_response.spec.ts deleted file mode 100644 index 4a70c48..0000000 --- a/tests/unit/services/cwmp/critical_reads_response.spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// Mock logger -vi.mock('@adonisjs/core/services/logger', () => ({ - default: { - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})) - -// Mock DeviceModel -vi.mock('../../../../app/models/device.js', () => ({ - default: { - createOrUpdate: vi.fn().mockResolvedValue({}), - findByDeviceId: vi.fn().mockResolvedValue(null), - updateOne: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock CommandModel -vi.mock('../../../../app/models/command.js', () => ({ - default: { - getCollection: vi.fn().mockReturnValue({ - find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), - countDocuments: vi.fn().mockResolvedValue(0), - }), - updateCommand: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock configuration evaluation (skip config eval for these tests) -vi.mock('../../../../app/services/cwmp/configuration_evaluation_state.js', () => ({ - ConfigurationEvaluationState: class { - name = 'configuration-evaluation' - async handle() { return { isComplete: true, response: '' } } - }, -})) - -// Mock reclassify -vi.mock('../../../../app/services/parameter_writability.js', () => ({ - reclassifyDeviceFromCatalog: vi.fn(), -})) - -import { MainStateMachine } from '../../../../app/services/cwmp/main_state_machine.js' -import { Device } from '../../../../app/domain/device/device.js' -import { DeviceId } from '../../../../app/domain/device/device_id.js' -import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js' - -function makeDevice(): Device { - const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) - device.setDataModel('tr-181') - device.setGpnStatus('unsupported') // Skip GPN, go to CriticalReads - return device -} - -describe('CriticalReadsState — GPV response processing', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('processes GPV response and updates device values', async () => { - const device = makeDevice() - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'session-1', taskQueue) - - // Start: IdleState → CriticalReadsState → sends GPV - const gpvRequest = await sm.start() - expect(gpvRequest).toContain('GetParameterValues') - - // Simulate GPVResponse with ParameterValueStruct - await sm.handleResponse(true, { - ParameterList: { - ParameterValueStruct: [ - { Name: 'Device.DeviceInfo.HardwareVersion', Value: { _: 'v3.0', '$xsi:type': 'xsd:string' } }, - { Name: 'Device.DeviceInfo.SoftwareVersion', Value: { _: 'SR50-5.0.0', '$xsi:type': 'xsd:string' } }, - { Name: 'Device.DeviceInfo.ModelName', Value: { _: 'Livebox 4', '$xsi:type': 'xsd:string' } }, - ], - }, - }) - - // Device should have the values stored - const hwProp = device.getProperty('Device.DeviceInfo.HardwareVersion') - expect(hwProp).not.toBeNull() - expect(hwProp!.value).toBe('v3.0') - - const swProp = device.getProperty('Device.DeviceInfo.SoftwareVersion') - expect(swProp).not.toBeNull() - expect(swProp!.value).toBe('SR50-5.0.0') - }) - - it('normalizes Name fields from object to string when xsi:type attribute is present', async () => { - const device = makeDevice() - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'session-1', taskQueue) - - await sm.start() - - // Simulate GPVResponse where Name has xsi:type attribute (parsed as object by XMLParser) - await sm.handleResponse(true, { - ParameterList: { - ParameterValueStruct: [ - { - Name: { _: 'Device.DeviceInfo.HardwareVersion', '$xsi:type': 'xsd:string' }, - Value: { _: 'v2.1', '$xsi:type': 'xsd:string' }, - }, - ], - }, - }) - - // Should normalize Name._ to string and store correctly - const hwProp = device.getProperty('Device.DeviceInfo.HardwareVersion') - expect(hwProp).not.toBeNull() - expect(hwProp!.value).toBe('v2.1') - }) - - it('handles empty ParameterValueStruct gracefully', async () => { - const device = makeDevice() - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'session-1', taskQueue) - - await sm.start() - - // Simulate empty GPV response - await sm.handleResponse(true, { ParameterList: { ParameterValueStruct: [] } }) - - // Should not throw, machine should continue - expect(sm.getCurrentStateName()).not.toBe('critical-reads') - }) - - it('handles missing ParameterList gracefully', async () => { - const device = makeDevice() - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'session-1', taskQueue) - - await sm.start() - - // Simulate response with no ParameterList - await sm.handleResponse(true, {}) - - // Should not throw, machine should continue - expect(sm.getCurrentStateName()).not.toBe('critical-reads') - }) - - it('transitions to configuration-evaluation after processing', async () => { - const device = makeDevice() - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'session-1', taskQueue) - - await sm.start() - await sm.handleResponse(true, { - ParameterList: { - ParameterValueStruct: [ - { Name: 'Device.DeviceInfo.HardwareVersion', Value: { _: 'v1', '$xsi:type': 'xsd:string' } }, - ], - }, - }) - - // Should have transitioned past critical-reads to configuration-evaluation - expect(sm.getCurrentStateName()).not.toBe('critical-reads') - expect(sm.getCurrentStateName()).toBe('configuration-evaluation') - }) -}) diff --git a/tests/unit/services/cwmp/device_action_executor.spec.ts b/tests/unit/services/cwmp/device_action_executor.spec.ts index 141deef..5f6a00a 100644 --- a/tests/unit/services/cwmp/device_action_executor.spec.ts +++ b/tests/unit/services/cwmp/device_action_executor.spec.ts @@ -3,7 +3,7 @@ import { DeviceActionExecutor } from '../../../../app/services/cwmp/device_actio import { CWMPTaskQueue, ResultStatus } from '../../../../app/services/cwmp/task_queue.js' import { Device } from '../../../../app/domain/device/device.js' import { DeviceId } from '../../../../app/domain/device/device_id.js' -import { DeviceProperty } from '../../../../app/domain/device/device_parameter.js' +import { DeviceProperty, DeviceParameter } from '../../../../app/domain/device/device_parameter.js' import { ParameterType } from '../../../../app/domain/shared/types.js' // Mock AdonisJS logger @@ -215,3 +215,508 @@ describe('DeviceActionExecutor', () => { }) }) }) + + +describe('DeviceActionExecutor — coverage', () => { + let taskQueue: CWMPTaskQueue + let executor: DeviceActionExecutor + + beforeEach(() => { + vi.clearAllMocks() + taskQueue = new CWMPTaskQueue() + executor = new DeviceActionExecutor(taskQueue) + }) + + // --- getParameterValues --- + + it('getParameterValues returns XML containing GetParameterValues', async () => { + const device = makeDevice() + const xml = await executor.getParameterValues(device, ['Device.Info.SW'], async () => '') + expect(xml).toContain('GetParameterValues') + expect(xml).toContain('Device.Info.SW') + }) + + it('getParameterValues wrappedCallback updates device on success', async () => { + const device = makeDevice() + await executor.getParameterValues(device, ['Device.Info.SW'], async () => '') + + // Execute the callback directly via executeTaskCallback + const taskCallbacks = (taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + expect(taskId).toBeDefined() + + await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, { + ParameterList: { + ParameterValueStruct: [ + { Name: 'Device.Info.SW', Value: { _: '5.0.1', $: { Type: 'xsd:string' } } }, + ], + }, + }) + + // Device should have been updated + const prop = device.getProperty('Device.Info.SW') + expect(prop).not.toBeNull() + expect(prop!.value).toBe('5.0.1') + }) + + // --- getParameterNames --- + + it('getParameterNames returns GPN envelope', async () => { + const device = makeDevice() + const xml = await executor.getParameterNames(device, 'Device.ManagementServer.', async () => '') + expect(xml).toContain('GetParameterNames') + expect(xml).toContain('Device.ManagementServer.') + }) + + it('GPN success marks device as supported and records success', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + let capturedCallback: any + vi.spyOn(taskQueue, 'addTask').mockImplementation((_deviceId, _type, _props, cb) => { + capturedCallback = cb + return { xml: '', taskId: 'tid-1' } + }) + + await executor.getParameterNames(device, 'Device.', async () => '') + await capturedCallback({ status: ResultStatus.Success, data: { ParameterList: { ParameterInfoStruct: [] } } }) + + expect(device.gpnStatus).toBe('supported') + expect(device.gpnCircuitBreaker.failureCount).toBe(0) + }) + + it('GPN fault 9001 marks device as unsupported and records failure', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + let capturedCallback: any + vi.spyOn(taskQueue, 'addTask').mockImplementation((_deviceId, _type, _props, cb) => { + capturedCallback = cb + return { xml: '', taskId: 'tid-2' } + }) + + await executor.getParameterNames(device, 'Device.', async () => '') + await capturedCallback({ status: ResultStatus.Error, data: { FaultCode: '9001', FaultString: 'Not supported' } }) + + expect(device.gpnStatus).toBe('unsupported') + expect(device.gpnCircuitBreaker.failureCount).toBe(1) + }) + + it('GPN fault 9005 marks device as unsupported', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + let capturedCallback: any + vi.spyOn(taskQueue, 'addTask').mockImplementation((_deviceId, _type, _props, cb) => { + capturedCallback = cb + return { xml: '', taskId: 'tid-3' } + }) + + await executor.getParameterNames(device, 'Device.', async () => '') + await capturedCallback({ status: ResultStatus.Error, data: { FaultCode: 9005 } }) + + expect(device.gpnStatus).toBe('unsupported') + expect(device.gpnCircuitBreaker.failureCount).toBe(1) + }) + + it('GPN fault 9002 is treated as transient (no unsupported mark)', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + let capturedCallback: any + vi.spyOn(taskQueue, 'addTask').mockImplementation((_deviceId, _type, _props, cb) => { + capturedCallback = cb + return { xml: '', taskId: 'tid-4' } + }) + + await executor.getParameterNames(device, 'Device.', async () => '') + await capturedCallback({ status: ResultStatus.Error, data: { FaultCode: '9002' } }) + + // Should NOT mark as unsupported + expect(device.gpnStatus).toBe('unknown') + expect(device.gpnCircuitBreaker.failureCount).toBe(0) + }) + + // --- applySetParameterValues --- + + it('applySetParameterValues returns SPV envelope', async () => { + const device = makeDevice() + const xml = await executor.applySetParameterValues( + device, + [{ parameterName: 'Device.X.Interval', value: '300', type: 'unsignedInt' }], + async () => '' + ) + expect(xml).toContain('SetParameterValues') + expect(xml).toContain('Device.X.Interval') + expect(xml).toContain('300') + expect(xml).toContain('xsd:unsignedInt') + }) + + it('applySetParameterValues infers type from device param when not specified', async () => { + const device = makeDevice() + device.setParameter(new DeviceParameter('Device.X.Flag', 'true', ParameterType.BOOLEAN)) + + const xml = await executor.applySetParameterValues( + device, + [{ parameterName: 'Device.X.Flag', value: 'false' }], + async () => '' + ) + expect(xml).toContain('xsd:boolean') + }) + + it('applySetParameterValues defaults to xsd:string for unknown type', async () => { + const device = makeDevice() + const xml = await executor.applySetParameterValues( + device, + [{ parameterName: 'Device.X.New', value: 'hello' }], + async () => '' + ) + expect(xml).toContain('xsd:string') + }) + + // --- insertProvisioningCode --- + + it('insertProvisioningCode uses tr-181 path for tr-181 device', async () => { + const device = makeDevice('tr-181') + device.tmpPC = 'my-prov-code' + const xml = await executor.insertProvisioningCode(device, async () => '') + expect(xml).toContain('Device.DeviceInfo.ProvisioningCode') + expect(xml).toContain('my-prov-code') + }) + + it('insertProvisioningCode uses tr-098 path for tr-098 device', async () => { + const device = makeDevice('tr-098') + device.tmpPC = 'code-098' + const xml = await executor.insertProvisioningCode(device, async () => '') + expect(xml).toContain('InternetGatewayDevice.DeviceInfo.ProvisioningCode') + }) + + it('insertProvisioningCode uses known param path when available', async () => { + const device = makeDevice('tr-181') + device.setProperty(new DeviceProperty('Device.DeviceInfo.ProvisioningCode', 'old-code', ParameterType.STRING)) + device.tmpPC = 'new-code' + const xml = await executor.insertProvisioningCode(device, async () => '') + expect(xml).toContain('Device.DeviceInfo.ProvisioningCode') + expect(xml).toContain('new-code') + }) + + // --- persistDevice --- + + it('persistDevice calls DeviceModel.createOrUpdate', async () => { + const device = makeDevice() + let capturedCallback: any + vi.spyOn(taskQueue, 'addTask').mockImplementation((_deviceId, _type, _props, cb) => { + capturedCallback = cb + return { xml: '', taskId: 'tid-p' } + }) + + await executor.getParameterValues(device, ['Device.X'], async () => '') + await capturedCallback({ + status: ResultStatus.Success, + data: { ParameterList: { ParameterValueStruct: [{ Name: 'Device.X', Value: { _: 'v' } }] } }, + }) + + expect(vi.mocked(DeviceModel.createOrUpdate)).toHaveBeenCalledWith( + expect.objectContaining({ identifier: expect.any(String) }) + ) + }) + + // --- Download/Reboot --- + + it('applyDownload returns Download envelope', async () => { + const device = makeDevice() + const xml = await executor.applyDownload(device, { + commandKey: 'fw-1', + fileType: '1 Firmware Upgrade Image', + url: 'http://example.com/fw.bin', + }, async () => '') + expect(xml).toContain('Download') + expect(xml).toContain('http://example.com/fw.bin') + }) + + it('applyReboot returns Reboot envelope', async () => { + const device = makeDevice() + const xml = await executor.applyReboot(device, { commandKey: 'reboot-1' }, async () => '') + expect(xml).toContain('Reboot') + expect(xml).toContain('reboot-1') + }) + + it('applyFactoryReset returns FactoryReset envelope', async () => { + const device = makeDevice() + const xml = await executor.applyFactoryReset(device, {}, async () => '') + expect(xml).toContain('FactoryReset') + }) +}) + +describe('DeviceActionExecutor — callback execution for remaining uncovered lines', () => { + let taskQueue: CWMPTaskQueue + let executor: DeviceActionExecutor + + beforeEach(() => { + vi.clearAllMocks() + taskQueue = new CWMPTaskQueue() + executor = new DeviceActionExecutor(taskQueue) + }) + + // --- getParameterAttributes callback (lines 118-145) --- + + it('getParameterAttributes wrappedCallback processes attributes on success', async () => { + const device = makeDevice() + device.setParameter(new DeviceParameter('Device.X.Param', 'val', ParameterType.STRING)) + + await executor.getParameterAttributes(device, ['Device.X.Param'], async () => '') + + const taskCallbacks = (taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + + await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, { + ParameterList: { + ParameterAttributeStruct: [ + { Name: 'Device.X.Param', Notification: 2, AccessList: { string: ['Subscriber'] } }, + ], + }, + }) + + // Should have updated the parameter's attributes + const param = device.getParameter('Device.X.Param') + expect(param).not.toBeNull() + }) + + it('getParameterAttributes wrappedCallback handles property attributes', async () => { + const device = makeDevice() + device.setProperty(new DeviceProperty('Device.Y.Prop', 'pval', ParameterType.STRING)) + + await executor.getParameterAttributes(device, ['Device.Y.Prop'], async () => '') + + const taskCallbacks = (taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + + await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, { + ParameterList: { + ParameterAttributeStruct: [ + { Name: 'Device.Y.Prop', Notification: 1 }, + ], + }, + }) + + const prop = device.getProperty('Device.Y.Prop') + expect(prop).not.toBeNull() + }) + + it('getParameterAttributes callback skips entries without Name', async () => { + const device = makeDevice() + await executor.getParameterAttributes(device, ['Device.X'], async () => '') + + const taskCallbacks = (taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + + // Entry without Name should be skipped without error + await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, { + ParameterList: { ParameterAttributeStruct: [{ Notification: 2 }] }, + }) + }) + + // --- persistDevice error path (line 81) --- + + it('persistDevice logs error when createOrUpdate fails', async () => { + vi.mocked(DeviceModel.createOrUpdate).mockRejectedValueOnce(new Error('DB down')) + const device = makeDevice() + + await executor.getParameterValues(device, ['Device.X'], async () => '') + const taskCallbacks = (taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + + await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, { + ParameterList: { ParameterValueStruct: [{ Name: 'Device.X', Value: { _: 'v' } }] }, + }) + + expect(vi.mocked(logger.error)).toHaveBeenCalled() + }) + + // --- getParameterValues wrappedCallback error path (line 103) --- + + it('getParameterValues wrappedCallback catches errors in updateFromValues', async () => { + const device = makeDevice() + // Force updateFromValues to throw + vi.spyOn(device, 'updateFromValues').mockImplementationOnce(() => { throw new Error('parse error') }) + + await executor.getParameterValues(device, ['Device.X'], async () => '') + const taskCallbacks = (taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + + await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, { + ParameterList: { ParameterValueStruct: [{ Name: 'Device.X', Value: { _: 'v' } }] }, + }) + + expect(vi.mocked(logger.error)).toHaveBeenCalled() + }) + + // --- applySetParameterValues callback (line 247) --- + + it('applySetParameterValues callback updates device on success', async () => { + const device = makeDevice() + const callbackResult = vi.fn().mockResolvedValue('') + + await executor.applySetParameterValues(device, [{ parameterName: 'Device.X', value: '42', type: 'int' }], callbackResult) + + const taskCallbacks = (taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, {}) + + expect(callbackResult).toHaveBeenCalledWith(true) + }) + + it('applySetParameterValues callback returns false on failure', async () => { + const device = makeDevice() + const callbackResult = vi.fn().mockResolvedValue('') + + await executor.applySetParameterValues(device, [{ parameterName: 'Device.X', value: '42' }], callbackResult) + + const taskCallbacks = (taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + await taskQueue.executeTaskCallback(taskId!, ResultStatus.Error, { FaultCode: 9001 }) + + expect(callbackResult).toHaveBeenCalledWith(false) + }) + + // --- applySetParameterAttributes callback (lines 270-273) --- + + it('applySetParameterAttributes callback invoked on success', async () => { + const device = makeDevice() + const cb = vi.fn().mockResolvedValue('') + + await executor.applySetParameterAttributes(device, [ + { name: 'Device.X', notificationChange: true, notification: 2, accessListChange: false, accessList: [] }, + ], cb) + + const taskCallbacks = (taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, {}) + + expect(cb).toHaveBeenCalledWith(true) + }) + + // --- applyScheduleInform callback (line 289) --- + + it('applyScheduleInform callback invoked', async () => { + const device = makeDevice() + const cb = vi.fn().mockResolvedValue('') + + await executor.applyScheduleInform(device, { delayInSeconds: 600 }, cb) + + const taskCallbacks = (taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, {}) + + expect(cb).toHaveBeenCalledWith(true) + }) + + // --- applyReboot callback (line 319) --- + + it('applyReboot callback invoked on success', async () => { + const device = makeDevice() + const cb = vi.fn().mockResolvedValue('') + + await executor.applyReboot(device, { commandKey: 'r1' }, cb) + + const taskCallbacks = (taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, {}) + + expect(cb).toHaveBeenCalledWith(true) + }) + + // --- applyUpload callback (line 367) --- + + it('applyUpload returns Upload envelope and callback invoked', async () => { + const device = makeDevice() + const cb = vi.fn().mockResolvedValue('') + + const xml = await executor.applyUpload(device, { + commandKey: 'upload-1', fileType: '2 Web Content', url: 'http://upload.example.com/cfg', + }, cb) + + expect(xml).toContain('Upload') + expect(xml).toContain('http://upload.example.com/cfg') + + const taskCallbacks = (taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, {}) + + expect(cb).toHaveBeenCalledWith(true) + }) + + // --- insertProvisioningCode success callback (line 409) --- + + it('insertProvisioningCode callback clears tmpPC on success', async () => { + const device = makeDevice() + device.tmpPC = 'my-prov-code' + const cb = vi.fn().mockResolvedValue('') + + await executor.insertProvisioningCode(device, cb) + + const taskCallbacks = (taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, {}) + + expect(cb).toHaveBeenCalledWith(true) + expect(device.tmpPC).toBe('') + }) + + it('insertProvisioningCode callback returns false on failure', async () => { + const device = makeDevice() + device.tmpPC = 'prov-code' + const cb = vi.fn().mockResolvedValue('') + + await executor.insertProvisioningCode(device, cb) + + const taskCallbacks = (taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + await taskQueue.executeTaskCallback(taskId!, ResultStatus.Error, {}) + + expect(cb).toHaveBeenCalledWith(false) + // tmpPC should NOT be cleared on failure + expect(device.tmpPC).toBe('prov-code') + }) + + // --- GPN with nested fault code path (lines 204-205) --- + + it('GPN fault with nested Detail.Fault.FaultCode marks as unsupported', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + let capturedCallback: any + vi.spyOn(taskQueue, 'addTask').mockImplementation((_deviceId, _type, _props, cb) => { + capturedCallback = cb + return { xml: '', taskId: 'tid-nested' } + }) + + await executor.getParameterNames(device, 'Device.', async () => '') + await capturedCallback({ + status: ResultStatus.Error, + data: { Detail: { Fault: { FaultCode: '9003' } } }, + }) + + expect(device.gpnStatus).toBe('unsupported') + }) + + // --- insertProvisioningCode legacy fallback path --- + + it('insertProvisioningCode uses legacy fallback when dataModel is unknown', async () => { + const device = makeDevice('unknown') + // No Device. prefixed params, so should use InternetGatewayDevice path + device.tmpPC = 'test-pc' + + const xml = await executor.insertProvisioningCode(device, async () => '') + expect(xml).toContain('InternetGatewayDevice.DeviceInfo.ProvisioningCode') + }) + + it('insertProvisioningCode uses Device path when device has Device. params', async () => { + const device = makeDevice('unknown') + device.setProperty(new DeviceProperty('Device.DeviceInfo.X', 'val', ParameterType.STRING)) + device.tmpPC = 'test-pc' + + const xml = await executor.insertProvisioningCode(device, async () => '') + expect(xml).toContain('Device.DeviceInfo.ProvisioningCode') + }) +}) + +import logger from '@adonisjs/core/services/logger' +import DeviceModel from '../../../../app/models/device.js' + + diff --git a/tests/unit/services/cwmp/device_action_executor_coverage.spec.ts b/tests/unit/services/cwmp/device_action_executor_coverage.spec.ts deleted file mode 100644 index 3ad9e60..0000000 --- a/tests/unit/services/cwmp/device_action_executor_coverage.spec.ts +++ /dev/null @@ -1,531 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// Mock logger -vi.mock('@adonisjs/core/services/logger', () => ({ - default: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, -})) - -// Mock DeviceModel -vi.mock('../../../../app/models/device.js', () => ({ - default: { - createOrUpdate: vi.fn().mockResolvedValue({}), - findByDeviceId: vi.fn().mockResolvedValue(null), - updateOne: vi.fn().mockResolvedValue({}), - }, -})) - -import { DeviceActionExecutor } from '../../../../app/services/cwmp/device_action_executor.js' -import { CWMPTaskQueue, ResultStatus } from '../../../../app/services/cwmp/task_queue.js' -import { Device } from '../../../../app/domain/device/device.js' -import { DeviceId } from '../../../../app/domain/device/device_id.js' -import { DeviceProperty, DeviceParameter } from '../../../../app/domain/device/device_parameter.js' -import { ParameterType } from '../../../../app/domain/shared/types.js' -import DeviceModel from '../../../../app/models/device.js' - -function makeDevice(dataModel: string = 'tr-181'): Device { - const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) - device.setDataModel(dataModel as any) - return device -} - -describe('DeviceActionExecutor — coverage', () => { - let taskQueue: CWMPTaskQueue - let executor: DeviceActionExecutor - - beforeEach(() => { - vi.clearAllMocks() - taskQueue = new CWMPTaskQueue() - executor = new DeviceActionExecutor(taskQueue) - }) - - // --- getParameterValues --- - - it('getParameterValues returns XML containing GetParameterValues', async () => { - const device = makeDevice() - const xml = await executor.getParameterValues(device, ['Device.Info.SW'], async () => '') - expect(xml).toContain('GetParameterValues') - expect(xml).toContain('Device.Info.SW') - }) - - it('getParameterValues wrappedCallback updates device on success', async () => { - const device = makeDevice() - await executor.getParameterValues(device, ['Device.Info.SW'], async () => '') - - // Execute the callback directly via executeTaskCallback - const taskCallbacks = (taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - expect(taskId).toBeDefined() - - await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, { - ParameterList: { - ParameterValueStruct: [ - { Name: 'Device.Info.SW', Value: { _: '5.0.1', $: { Type: 'xsd:string' } } }, - ], - }, - }) - - // Device should have been updated - const prop = device.getProperty('Device.Info.SW') - expect(prop).not.toBeNull() - expect(prop!.value).toBe('5.0.1') - }) - - // --- getParameterNames --- - - it('getParameterNames returns GPN envelope', async () => { - const device = makeDevice() - const xml = await executor.getParameterNames(device, 'Device.ManagementServer.', async () => '') - expect(xml).toContain('GetParameterNames') - expect(xml).toContain('Device.ManagementServer.') - }) - - it('GPN success marks device as supported and records success', async () => { - const device = makeDevice() - device.setGpnStatus('unknown') - let capturedCallback: any - vi.spyOn(taskQueue, 'addTask').mockImplementation((_deviceId, _type, _props, cb) => { - capturedCallback = cb - return { xml: '', taskId: 'tid-1' } - }) - - await executor.getParameterNames(device, 'Device.', async () => '') - await capturedCallback({ status: ResultStatus.Success, data: { ParameterList: { ParameterInfoStruct: [] } } }) - - expect(device.gpnStatus).toBe('supported') - expect(device.gpnCircuitBreaker.failureCount).toBe(0) - }) - - it('GPN fault 9001 marks device as unsupported and records failure', async () => { - const device = makeDevice() - device.setGpnStatus('unknown') - let capturedCallback: any - vi.spyOn(taskQueue, 'addTask').mockImplementation((_deviceId, _type, _props, cb) => { - capturedCallback = cb - return { xml: '', taskId: 'tid-2' } - }) - - await executor.getParameterNames(device, 'Device.', async () => '') - await capturedCallback({ status: ResultStatus.Error, data: { FaultCode: '9001', FaultString: 'Not supported' } }) - - expect(device.gpnStatus).toBe('unsupported') - expect(device.gpnCircuitBreaker.failureCount).toBe(1) - }) - - it('GPN fault 9005 marks device as unsupported', async () => { - const device = makeDevice() - device.setGpnStatus('unknown') - let capturedCallback: any - vi.spyOn(taskQueue, 'addTask').mockImplementation((_deviceId, _type, _props, cb) => { - capturedCallback = cb - return { xml: '', taskId: 'tid-3' } - }) - - await executor.getParameterNames(device, 'Device.', async () => '') - await capturedCallback({ status: ResultStatus.Error, data: { FaultCode: 9005 } }) - - expect(device.gpnStatus).toBe('unsupported') - expect(device.gpnCircuitBreaker.failureCount).toBe(1) - }) - - it('GPN fault 9002 is treated as transient (no unsupported mark)', async () => { - const device = makeDevice() - device.setGpnStatus('unknown') - let capturedCallback: any - vi.spyOn(taskQueue, 'addTask').mockImplementation((_deviceId, _type, _props, cb) => { - capturedCallback = cb - return { xml: '', taskId: 'tid-4' } - }) - - await executor.getParameterNames(device, 'Device.', async () => '') - await capturedCallback({ status: ResultStatus.Error, data: { FaultCode: '9002' } }) - - // Should NOT mark as unsupported - expect(device.gpnStatus).toBe('unknown') - expect(device.gpnCircuitBreaker.failureCount).toBe(0) - }) - - // --- applySetParameterValues --- - - it('applySetParameterValues returns SPV envelope', async () => { - const device = makeDevice() - const xml = await executor.applySetParameterValues( - device, - [{ parameterName: 'Device.X.Interval', value: '300', type: 'unsignedInt' }], - async () => '' - ) - expect(xml).toContain('SetParameterValues') - expect(xml).toContain('Device.X.Interval') - expect(xml).toContain('300') - expect(xml).toContain('xsd:unsignedInt') - }) - - it('applySetParameterValues infers type from device param when not specified', async () => { - const device = makeDevice() - device.setParameter(new DeviceParameter('Device.X.Flag', 'true', ParameterType.BOOLEAN)) - - const xml = await executor.applySetParameterValues( - device, - [{ parameterName: 'Device.X.Flag', value: 'false' }], - async () => '' - ) - expect(xml).toContain('xsd:boolean') - }) - - it('applySetParameterValues defaults to xsd:string for unknown type', async () => { - const device = makeDevice() - const xml = await executor.applySetParameterValues( - device, - [{ parameterName: 'Device.X.New', value: 'hello' }], - async () => '' - ) - expect(xml).toContain('xsd:string') - }) - - // --- insertProvisioningCode --- - - it('insertProvisioningCode uses tr-181 path for tr-181 device', async () => { - const device = makeDevice('tr-181') - device.tmpPC = 'my-prov-code' - const xml = await executor.insertProvisioningCode(device, async () => '') - expect(xml).toContain('Device.DeviceInfo.ProvisioningCode') - expect(xml).toContain('my-prov-code') - }) - - it('insertProvisioningCode uses tr-098 path for tr-098 device', async () => { - const device = makeDevice('tr-098') - device.tmpPC = 'code-098' - const xml = await executor.insertProvisioningCode(device, async () => '') - expect(xml).toContain('InternetGatewayDevice.DeviceInfo.ProvisioningCode') - }) - - it('insertProvisioningCode uses known param path when available', async () => { - const device = makeDevice('tr-181') - device.setProperty(new DeviceProperty('Device.DeviceInfo.ProvisioningCode', 'old-code', ParameterType.STRING)) - device.tmpPC = 'new-code' - const xml = await executor.insertProvisioningCode(device, async () => '') - expect(xml).toContain('Device.DeviceInfo.ProvisioningCode') - expect(xml).toContain('new-code') - }) - - // --- persistDevice --- - - it('persistDevice calls DeviceModel.createOrUpdate', async () => { - const device = makeDevice() - let capturedCallback: any - vi.spyOn(taskQueue, 'addTask').mockImplementation((_deviceId, _type, _props, cb) => { - capturedCallback = cb - return { xml: '', taskId: 'tid-p' } - }) - - await executor.getParameterValues(device, ['Device.X'], async () => '') - await capturedCallback({ - status: ResultStatus.Success, - data: { ParameterList: { ParameterValueStruct: [{ Name: 'Device.X', Value: { _: 'v' } }] } }, - }) - - expect(vi.mocked(DeviceModel.createOrUpdate)).toHaveBeenCalledWith( - expect.objectContaining({ identifier: expect.any(String) }) - ) - }) - - // --- Download/Reboot --- - - it('applyDownload returns Download envelope', async () => { - const device = makeDevice() - const xml = await executor.applyDownload(device, { - commandKey: 'fw-1', - fileType: '1 Firmware Upgrade Image', - url: 'http://example.com/fw.bin', - }, async () => '') - expect(xml).toContain('Download') - expect(xml).toContain('http://example.com/fw.bin') - }) - - it('applyReboot returns Reboot envelope', async () => { - const device = makeDevice() - const xml = await executor.applyReboot(device, { commandKey: 'reboot-1' }, async () => '') - expect(xml).toContain('Reboot') - expect(xml).toContain('reboot-1') - }) - - it('applyFactoryReset returns FactoryReset envelope', async () => { - const device = makeDevice() - const xml = await executor.applyFactoryReset(device, {}, async () => '') - expect(xml).toContain('FactoryReset') - }) -}) - -describe('DeviceActionExecutor — callback execution for remaining uncovered lines', () => { - let taskQueue: CWMPTaskQueue - let executor: DeviceActionExecutor - - beforeEach(() => { - vi.clearAllMocks() - taskQueue = new CWMPTaskQueue() - executor = new DeviceActionExecutor(taskQueue) - }) - - // --- getParameterAttributes callback (lines 118-145) --- - - it('getParameterAttributes wrappedCallback processes attributes on success', async () => { - const device = makeDevice() - device.setParameter(new DeviceParameter('Device.X.Param', 'val', ParameterType.STRING)) - - await executor.getParameterAttributes(device, ['Device.X.Param'], async () => '') - - const taskCallbacks = (taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - - await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, { - ParameterList: { - ParameterAttributeStruct: [ - { Name: 'Device.X.Param', Notification: 2, AccessList: { string: ['Subscriber'] } }, - ], - }, - }) - - // Should have updated the parameter's attributes - const param = device.getParameter('Device.X.Param') - expect(param).not.toBeNull() - }) - - it('getParameterAttributes wrappedCallback handles property attributes', async () => { - const device = makeDevice() - device.setProperty(new DeviceProperty('Device.Y.Prop', 'pval', ParameterType.STRING)) - - await executor.getParameterAttributes(device, ['Device.Y.Prop'], async () => '') - - const taskCallbacks = (taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - - await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, { - ParameterList: { - ParameterAttributeStruct: [ - { Name: 'Device.Y.Prop', Notification: 1 }, - ], - }, - }) - - const prop = device.getProperty('Device.Y.Prop') - expect(prop).not.toBeNull() - }) - - it('getParameterAttributes callback skips entries without Name', async () => { - const device = makeDevice() - await executor.getParameterAttributes(device, ['Device.X'], async () => '') - - const taskCallbacks = (taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - - // Entry without Name should be skipped without error - await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, { - ParameterList: { ParameterAttributeStruct: [{ Notification: 2 }] }, - }) - }) - - // --- persistDevice error path (line 81) --- - - it('persistDevice logs error when createOrUpdate fails', async () => { - vi.mocked(DeviceModel.createOrUpdate).mockRejectedValueOnce(new Error('DB down')) - const device = makeDevice() - - await executor.getParameterValues(device, ['Device.X'], async () => '') - const taskCallbacks = (taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - - await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, { - ParameterList: { ParameterValueStruct: [{ Name: 'Device.X', Value: { _: 'v' } }] }, - }) - - expect(vi.mocked(logger.error)).toHaveBeenCalled() - }) - - // --- getParameterValues wrappedCallback error path (line 103) --- - - it('getParameterValues wrappedCallback catches errors in updateFromValues', async () => { - const device = makeDevice() - // Force updateFromValues to throw - vi.spyOn(device, 'updateFromValues').mockImplementationOnce(() => { throw new Error('parse error') }) - - await executor.getParameterValues(device, ['Device.X'], async () => '') - const taskCallbacks = (taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - - await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, { - ParameterList: { ParameterValueStruct: [{ Name: 'Device.X', Value: { _: 'v' } }] }, - }) - - expect(vi.mocked(logger.error)).toHaveBeenCalled() - }) - - // --- applySetParameterValues callback (line 247) --- - - it('applySetParameterValues callback updates device on success', async () => { - const device = makeDevice() - const callbackResult = vi.fn().mockResolvedValue('') - - await executor.applySetParameterValues(device, [{ parameterName: 'Device.X', value: '42', type: 'int' }], callbackResult) - - const taskCallbacks = (taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, {}) - - expect(callbackResult).toHaveBeenCalledWith(true) - }) - - it('applySetParameterValues callback returns false on failure', async () => { - const device = makeDevice() - const callbackResult = vi.fn().mockResolvedValue('') - - await executor.applySetParameterValues(device, [{ parameterName: 'Device.X', value: '42' }], callbackResult) - - const taskCallbacks = (taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - await taskQueue.executeTaskCallback(taskId!, ResultStatus.Error, { FaultCode: 9001 }) - - expect(callbackResult).toHaveBeenCalledWith(false) - }) - - // --- applySetParameterAttributes callback (lines 270-273) --- - - it('applySetParameterAttributes callback invoked on success', async () => { - const device = makeDevice() - const cb = vi.fn().mockResolvedValue('') - - await executor.applySetParameterAttributes(device, [ - { name: 'Device.X', notificationChange: true, notification: 2, accessListChange: false, accessList: [] }, - ], cb) - - const taskCallbacks = (taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, {}) - - expect(cb).toHaveBeenCalledWith(true) - }) - - // --- applyScheduleInform callback (line 289) --- - - it('applyScheduleInform callback invoked', async () => { - const device = makeDevice() - const cb = vi.fn().mockResolvedValue('') - - await executor.applyScheduleInform(device, { delayInSeconds: 600 }, cb) - - const taskCallbacks = (taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, {}) - - expect(cb).toHaveBeenCalledWith(true) - }) - - // --- applyReboot callback (line 319) --- - - it('applyReboot callback invoked on success', async () => { - const device = makeDevice() - const cb = vi.fn().mockResolvedValue('') - - await executor.applyReboot(device, { commandKey: 'r1' }, cb) - - const taskCallbacks = (taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, {}) - - expect(cb).toHaveBeenCalledWith(true) - }) - - // --- applyUpload callback (line 367) --- - - it('applyUpload returns Upload envelope and callback invoked', async () => { - const device = makeDevice() - const cb = vi.fn().mockResolvedValue('') - - const xml = await executor.applyUpload(device, { - commandKey: 'upload-1', fileType: '2 Web Content', url: 'http://upload.example.com/cfg', - }, cb) - - expect(xml).toContain('Upload') - expect(xml).toContain('http://upload.example.com/cfg') - - const taskCallbacks = (taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, {}) - - expect(cb).toHaveBeenCalledWith(true) - }) - - // --- insertProvisioningCode success callback (line 409) --- - - it('insertProvisioningCode callback clears tmpPC on success', async () => { - const device = makeDevice() - device.tmpPC = 'my-prov-code' - const cb = vi.fn().mockResolvedValue('') - - await executor.insertProvisioningCode(device, cb) - - const taskCallbacks = (taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - await taskQueue.executeTaskCallback(taskId!, ResultStatus.Success, {}) - - expect(cb).toHaveBeenCalledWith(true) - expect(device.tmpPC).toBe('') - }) - - it('insertProvisioningCode callback returns false on failure', async () => { - const device = makeDevice() - device.tmpPC = 'prov-code' - const cb = vi.fn().mockResolvedValue('') - - await executor.insertProvisioningCode(device, cb) - - const taskCallbacks = (taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - await taskQueue.executeTaskCallback(taskId!, ResultStatus.Error, {}) - - expect(cb).toHaveBeenCalledWith(false) - // tmpPC should NOT be cleared on failure - expect(device.tmpPC).toBe('prov-code') - }) - - // --- GPN with nested fault code path (lines 204-205) --- - - it('GPN fault with nested Detail.Fault.FaultCode marks as unsupported', async () => { - const device = makeDevice() - device.setGpnStatus('unknown') - let capturedCallback: any - vi.spyOn(taskQueue, 'addTask').mockImplementation((_deviceId, _type, _props, cb) => { - capturedCallback = cb - return { xml: '', taskId: 'tid-nested' } - }) - - await executor.getParameterNames(device, 'Device.', async () => '') - await capturedCallback({ - status: ResultStatus.Error, - data: { Detail: { Fault: { FaultCode: '9003' } } }, - }) - - expect(device.gpnStatus).toBe('unsupported') - }) - - // --- insertProvisioningCode legacy fallback path --- - - it('insertProvisioningCode uses legacy fallback when dataModel is unknown', async () => { - const device = makeDevice('unknown') - // No Device. prefixed params, so should use InternetGatewayDevice path - device.tmpPC = 'test-pc' - - const xml = await executor.insertProvisioningCode(device, async () => '') - expect(xml).toContain('InternetGatewayDevice.DeviceInfo.ProvisioningCode') - }) - - it('insertProvisioningCode uses Device path when device has Device. params', async () => { - const device = makeDevice('unknown') - device.setProperty(new DeviceProperty('Device.DeviceInfo.X', 'val', ParameterType.STRING)) - device.tmpPC = 'test-pc' - - const xml = await executor.insertProvisioningCode(device, async () => '') - expect(xml).toContain('Device.DeviceInfo.ProvisioningCode') - }) -}) - -import logger from '@adonisjs/core/services/logger' - diff --git a/tests/unit/services/cwmp/executing_commands.spec.ts b/tests/unit/services/cwmp/executing_commands.spec.ts deleted file mode 100644 index e4e18c3..0000000 --- a/tests/unit/services/cwmp/executing_commands.spec.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// Mock logger -vi.mock('@adonisjs/core/services/logger', () => ({ - default: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, -})) - -// Mock DeviceModel -vi.mock('../../../../app/models/device.js', () => ({ - default: { - createOrUpdate: vi.fn().mockResolvedValue({}), - findByDeviceId: vi.fn().mockResolvedValue(null), - updateOne: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock CommandModel -vi.mock('../../../../app/models/command.js', () => ({ - default: { - getCollection: vi.fn().mockReturnValue({ - find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), - countDocuments: vi.fn().mockResolvedValue(0), - }), - updateCommand: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock reclassify -vi.mock('../../../../app/services/parameter_writability.js', () => ({ - reclassifyDeviceFromCatalog: vi.fn(), -})) - -import { ExecutingCommandsState } from '../../../../app/services/cwmp/main_state_machine.js' -import { Device } from '../../../../app/domain/device/device.js' -import { DeviceId } from '../../../../app/domain/device/device_id.js' -import { DeviceParameter } from '../../../../app/domain/device/device_parameter.js' -import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js' -import { DeviceActionExecutor } from '../../../../app/services/cwmp/device_action_executor.js' -import { CommandStatus, CommandType } from '../../../../app/domain/commands/command.js' -import { ModeType } from '../../../../app/domain/shared/mode.js' -import { ParameterType } from '../../../../app/domain/shared/types.js' -import CommandModel from '../../../../app/models/command.js' -import logger from '@adonisjs/core/services/logger' - -function makeDevice(): Device { - const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) - device.setDataModel('tr-181') - device.setGpnStatus('unsupported') - return device -} - -function makeContext(device: Device) { - const taskQueue = new CWMPTaskQueue() - return { - device, - session: 'test-session', - taskQueue, - message: undefined as any, - services: { deviceAction: new DeviceActionExecutor(taskQueue) }, - } -} - -// Helper to inject mock commands into the ExecutingCommandsState flow -function setupCommandMock(commands: any[]) { - const collection = CommandModel.getCollection() - let callCount = 0 - vi.mocked(collection.find).mockImplementation(() => ({ - sort: () => ({ - toArray: () => { - const result = callCount === 0 ? commands : [] - callCount++ - return Promise.resolve(result) - } - }) - } as any)) -} - -describe('ExecutingCommandsState — direct testing', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('returns isComplete when no commands and includeNI=false', async () => { - setupCommandMock([]) - const device = makeDevice() - const context = makeContext(device) - const state = new ExecutingCommandsState(false) - - const result = await state.handle(context) - expect(result.isComplete).toBe(true) - }) - - it('transitions to OperationalState when no commands and includeNI=true', async () => { - setupCommandMock([]) - const device = makeDevice() - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - - const result = await state.handle(context) - expect(result.nextState).toBeDefined() - expect((result.nextState as any).name).toBe('operational') - }) - - it('sends GET_PARAMETER_VALUES command', async () => { - const cmd = { _id: 'c1', action: CommandType.GET_PARAMETER_VALUES, parameters: ['Device.Info.SW'], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } - setupCommandMock([cmd]) - - const device = makeDevice() - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - - const result = await state.handle(context) - expect(result.response).toContain('GetParameterValues') - expect(result.response).toContain('Device.Info.SW') - }) - - it('sends SET_PARAMETER_VALUES command', async () => { - const cmd = { _id: 'c2', action: CommandType.SET_PARAMETER_VALUES, parameters: [{ name: 'Device.X', value: '42', type: 'int' }], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } - setupCommandMock([cmd]) - - const device = makeDevice() - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - - const result = await state.handle(context) - expect(result.response).toContain('SetParameterValues') - expect(result.response).toContain('Device.X') - }) - - it('sends REBOOT command', async () => { - const cmd = { _id: 'c3', action: CommandType.REBOOT, parameters: { commandKey: 'reboot-1' }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } - setupCommandMock([cmd]) - - const device = makeDevice() - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - - const result = await state.handle(context) - expect(result.response).toContain('Reboot') - }) - - it('sends DOWNLOAD command', async () => { - const cmd = { _id: 'c4', action: CommandType.DOWNLOAD, parameters: { commandKey: 'fw', fileType: '1 Firmware', url: 'http://x.bin' }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } - setupCommandMock([cmd]) - - const device = makeDevice() - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - - const result = await state.handle(context) - expect(result.response).toContain('Download') - }) - - it('sends FACTORY_RESET command', async () => { - const cmd = { _id: 'c5', action: CommandType.FACTORY_RESET, parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } - setupCommandMock([cmd]) - - const device = makeDevice() - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - - const result = await state.handle(context) - expect(result.response).toContain('FactoryReset') - }) - - it('sends SCHEDULE_INFORM command', async () => { - const cmd = { _id: 'c6', action: CommandType.SCHEDULE_INFORM, parameters: { delayInSeconds: 600 }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } - setupCommandMock([cmd]) - - const device = makeDevice() - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - - const result = await state.handle(context) - expect(result.response).toContain('ScheduleInform') - expect(result.response).toContain('600') - }) - - it('sends REFRESH command (GPV on known params)', async () => { - const cmd = { _id: 'c7', action: CommandType.REFRESH, parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } - setupCommandMock([cmd]) - - const device = makeDevice() - device.setParameter(new DeviceParameter('Device.Info.SW', '5.0', ParameterType.STRING)) - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - - const result = await state.handle(context) - expect(result.response).toContain('GetParameterValues') - expect(result.response).toContain('Device.Info.SW') - }) - - it('handles unknown command type by marking as FAILED', async () => { - const cmd = { _id: 'c8', action: 'INVALID_TYPE', parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } - setupCommandMock([cmd]) - - const device = makeDevice() - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - - await state.handle(context) - - expect(vi.mocked(CommandModel.updateCommand)).toHaveBeenCalledWith( - 'c8', - expect.objectContaining({ status: CommandStatus.FAILED }) - ) - }) - - it('marks command COMPLETED on success response and does SPV optimistic update', async () => { - const cmd = { _id: 'c9', action: CommandType.SET_PARAMETER_VALUES, parameters: [{ name: 'Device.X.Val', value: 'new' }], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } - setupCommandMock([cmd]) - - const device = makeDevice() - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - - // First handle: loads commands, sends SPV - await state.handle(context) - - // Second handle: receives success response - context.message = { success: true, data: { Status: '0' } } - const result = await state.handle(context) - - expect(vi.mocked(CommandModel.updateCommand)).toHaveBeenCalledWith('c9', expect.objectContaining({ status: CommandStatus.COMPLETED })) - // Optimistic update - const prop = device.getProperty('Device.X.Val') - expect(prop).not.toBeNull() - expect(prop!.value).toBe('new') - // No more commands → transitions to operational - expect(result.nextState).toBeDefined() - expect((result.nextState as any).name).toBe('operational') - }) - - it('marks command FAILED on fault response', async () => { - const cmd = { _id: 'c10', action: CommandType.GET_PARAMETER_VALUES, parameters: ['Device.X'], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } - setupCommandMock([cmd]) - - const device = makeDevice() - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - - // First handle: loads commands, sends GPV - await state.handle(context) - - // Second handle: receives fault - context.message = { success: false, data: { FaultCode: 9001 } } - await state.handle(context) - - expect(vi.mocked(CommandModel.updateCommand)).toHaveBeenCalledWith('c10', expect.objectContaining({ status: CommandStatus.FAILED })) - }) -}) - -// GPN success flow is tested in main_state_machine_extra.spec.ts and gpn_circuit_breaker.spec.ts - -describe('ExecutingCommandsState — branch coverage', () => { - beforeEach(() => { vi.clearAllMocks() }) - - it('handles multiple commands in queue sequentially', async () => { - const cmd1 = { _id: 'c1', action: CommandType.GET_PARAMETER_VALUES, parameters: ['Device.A'], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 } - const cmd2 = { _id: 'c2', action: CommandType.REBOOT, parameters: { commandKey: 'r' }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 2 } - setupCommandMock([cmd1, cmd2]) - - const device = makeDevice() - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - - const r1 = await state.handle(context) - expect(r1.response).toContain('GetParameterValues') - - context.message = { success: true, data: {} } - const r2 = await state.handle(context) - expect(r2.response).toContain('Reboot') - - context.message = { success: true, data: {} } - const r3 = await state.handle(context) - expect(r3.nextState).toBeDefined() - expect((r3.nextState as any).name).toBe('operational') - }) - - it('GET_PARAMETER_NAMES command with string params', async () => { - setupCommandMock([{ _id: 'c3', action: CommandType.GET_PARAMETER_NAMES, parameters: 'Device.ManagementServer.', status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) - const result = await new ExecutingCommandsState(true).handle(makeContext(makeDevice())) - expect(result.response).toContain('GetParameterNames') - }) - - it('GET_PARAMETER_NAMES command with object params', async () => { - setupCommandMock([{ _id: 'c4', action: CommandType.GET_PARAMETER_NAMES, parameters: { path: 'Device.Info.' }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) - const result = await new ExecutingCommandsState(true).handle(makeContext(makeDevice())) - expect(result.response).toContain('GetParameterNames') - }) - - it('SET_PARAMETER_VALUES with parameterValues property format', async () => { - setupCommandMock([{ _id: 'c5', action: CommandType.SET_PARAMETER_VALUES, parameters: { parameterValues: [{ name: 'Device.X', value: '1' }] }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) - const result = await new ExecutingCommandsState(true).handle(makeContext(makeDevice())) - expect(result.response).toContain('SetParameterValues') - }) - - it('SET_PARAMETER_ATTRIBUTES command', async () => { - setupCommandMock([{ _id: 'c6', action: CommandType.SET_PARAMETER_ATTRIBUTES, parameters: [{ name: 'Device.X', notificationChange: true, notification: 2, accessListChange: false, accessList: [] }], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) - const result = await new ExecutingCommandsState(true).handle(makeContext(makeDevice())) - expect(result.response).toContain('SetParameterAttributes') - }) - - it('UPLOAD command', async () => { - setupCommandMock([{ _id: 'c7', action: CommandType.UPLOAD, parameters: { commandKey: 'u1', fileType: '2', url: 'http://u.bin' }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) - const result = await new ExecutingCommandsState(true).handle(makeContext(makeDevice())) - expect(result.response).toContain('Upload') - }) - - it('CommandModel.updateCommand error is caught', async () => { - setupCommandMock([{ _id: 'c8', action: CommandType.GET_PARAMETER_VALUES, parameters: ['Device.X'], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) - const device = makeDevice() - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - - await state.handle(context) - vi.mocked(CommandModel.updateCommand).mockRejectedValueOnce(new Error('DB error')) - context.message = { success: true, data: {} } - await state.handle(context) - expect(vi.mocked(logger.error)).toHaveBeenCalled() - }) - - it('returns empty response for includeNI=false when all commands done', async () => { - setupCommandMock([{ _id: 'c9', action: CommandType.REBOOT, parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) - const context = makeContext(makeDevice()) - const state = new ExecutingCommandsState(false) - - await state.handle(context) - context.message = { success: true, data: {} } - const result = await state.handle(context) - expect(result.response).toBe('') - expect(result.nextState).toBeUndefined() - }) -}) - diff --git a/tests/unit/services/cwmp/gpn_circuit_breaker.spec.ts b/tests/unit/services/cwmp/gpn_circuit_breaker.spec.ts deleted file mode 100644 index 627f820..0000000 --- a/tests/unit/services/cwmp/gpn_circuit_breaker.spec.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// Mock logger -vi.mock('@adonisjs/core/services/logger', () => ({ - default: { - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})) - -// Mock DeviceModel -vi.mock('../../../../app/models/device.js', () => ({ - default: { - createOrUpdate: vi.fn().mockResolvedValue({}), - findByDeviceId: vi.fn().mockResolvedValue(null), - updateOne: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock CommandModel -vi.mock('../../../../app/models/command.js', () => ({ - default: { - getCollection: vi.fn().mockReturnValue({ - find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), - countDocuments: vi.fn().mockResolvedValue(0), - }), - updateCommand: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock configuration engine -vi.mock('../../../../app/services/cwmp/configuration_evaluation_state.js', () => ({ - ConfigurationEvaluationState: class { - name = 'configuration-evaluation' - async handle() { return { isComplete: true, response: '' } } - }, -})) - -// Mock reclassify -vi.mock('../../../../app/services/parameter_writability.js', () => ({ - reclassifyDeviceFromCatalog: vi.fn(), -})) - -import { MainStateMachine } from '../../../../app/services/cwmp/main_state_machine.js' -import { Device } from '../../../../app/domain/device/device.js' -import { DeviceId } from '../../../../app/domain/device/device_id.js' -import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js' -import DeviceModel from '../../../../app/models/device.js' - -function makeDevice(): Device { - const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) - device.setDataModel('tr-181') - // Simulate unclassified Inform params so GPN test triggers - device.setTmpParamValues([ - { Name: 'Device.ManagementServer.ConnectionRequestURL', Value: { _: 'http://cpe/' } }, - ]) - return device -} - -describe('GPN Circuit Breaker — DiscoveringParametersState', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('IdleState skips GPN when gpnStatus is unsupported', async () => { - const device = makeDevice() - device.setGpnStatus('unsupported') - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'session-1', taskQueue) - - // Start triggers IdleState → should skip GPN - const response = await sm.start() - - // Should produce a GPV (critical reads) not a GPN - expect(response).toContain('GetParameterValues') - expect(response).not.toContain('GetParameterNames') - }) - - it('IdleState skips GPN when circuit breaker is open', async () => { - const device = makeDevice() - device.setGpnStatus('unknown') - device.setGpnCircuitBreaker({ - failureCount: 2, - lastFailureDate: new Date(), - state: 'open', - cooldownUntil: new Date(Date.now() + 86400000), - }) - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'session-1', taskQueue) - - const response = await sm.start() - - // Should produce a GPV (critical reads) not a GPN - expect(response).toContain('GetParameterValues') - expect(response).not.toContain('GetParameterNames') - }) - - it('IdleState proceeds to GPN when gpnStatus is unknown and circuit breaker is closed', async () => { - const device = makeDevice() - device.setGpnStatus('unknown') - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'session-1', taskQueue) - - const response = await sm.start() - - // Should produce a GPN request - expect(response).toContain('GetParameterNames') - }) - - it('DiscoveringParametersState processes GPN response and stores parameters', async () => { - const device = makeDevice() // tr-181 - device.setGpnStatus('unknown') - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'session-1', taskQueue) - - // Start: IdleState → DiscoveringParametersState → sends GPN for Device.ManagementServer. - await sm.start() - - // Simulate a successful GPN response → transitions to critical-reads - const gpnResponse = { - ParameterList: { - ParameterInfoStruct: [ - { Name: 'Device.ManagementServer.', Writable: false }, - { Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: true }, - { Name: 'Device.ManagementServer.ConnectionRequestURL', Writable: false }, - ], - }, - } - await sm.handleResponse(true, gpnResponse) - - // Should transition to critical-reads after single GPN - expect(sm.getCurrentStateName()).toBe('critical-reads') - // Device should be marked as GPN supported - expect(device.gpnStatus).toBe('supported') - }) - - it('DiscoveringParametersState sets gpnStatus supported even with empty param list', async () => { - const device = makeDevice() - device.setGpnStatus('unknown') - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'session-1', taskQueue) - - await sm.start() - - // Respond with empty list - await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } }) - - expect(device.gpnStatus).toBe('supported') - }) - - it('DiscoveringParametersState calls recordGpnFailure and persists on fault', async () => { - const device = makeDevice() - device.setGpnStatus('unknown') - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'session-1', taskQueue) - - // Start: IdleState → DiscoveringParametersState → sends GPN - await sm.start() - - // Simulate CPE fault response - const response = await sm.handleResponse(false, { FaultCode: '9001', FaultString: 'Method not supported' }) - - // Device should now be marked as unsupported - expect(device.gpnStatus).toBe('unsupported') - - // Circuit breaker should have recorded the failure - expect(device.gpnCircuitBreaker.failureCount).toBe(1) - - // Should have persisted via createOrUpdate - expect(DeviceModel.createOrUpdate).toHaveBeenCalled() - - // Should continue to critical reads (GPV) - expect(response).toContain('GetParameterValues') - }) -}) diff --git a/tests/unit/services/cwmp/immediate_command_sm.spec.ts b/tests/unit/services/cwmp/immediate_command_state_machine.spec.ts similarity index 100% rename from tests/unit/services/cwmp/immediate_command_sm.spec.ts rename to tests/unit/services/cwmp/immediate_command_state_machine.spec.ts diff --git a/tests/unit/services/cwmp/two_session_scenario.spec.ts b/tests/unit/services/cwmp/main_state_machine.integration.spec.ts similarity index 99% rename from tests/unit/services/cwmp/two_session_scenario.spec.ts rename to tests/unit/services/cwmp/main_state_machine.integration.spec.ts index 555463f..a205030 100644 --- a/tests/unit/services/cwmp/two_session_scenario.spec.ts +++ b/tests/unit/services/cwmp/main_state_machine.integration.spec.ts @@ -398,3 +398,4 @@ describe('GPN unsupported → catalog classification + persistence', () => { }) }) + diff --git a/tests/unit/services/cwmp/main_state_machine.spec.ts b/tests/unit/services/cwmp/main_state_machine.spec.ts new file mode 100644 index 0000000..9f57be3 --- /dev/null +++ b/tests/unit/services/cwmp/main_state_machine.spec.ts @@ -0,0 +1,760 @@ +import { describe, it, expect, vi } from 'vitest' + +vi.mock('@adonisjs/core/services/logger', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) +vi.mock('#models/device', () => ({ + default: { + createOrUpdate: vi.fn().mockResolvedValue({}), + updateOne: vi.fn().mockResolvedValue({}), + }, +})) +vi.mock('../../../../app/models/command.js', () => ({ + default: { + getCollection: () => ({ + find: vi.fn().mockReturnValue({ + sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }), + }), + }), + updateCommand: vi.fn().mockResolvedValue({}), + }, +})) + +import { + MainStateMachine, + ExecutingCommandsState, +} from '../../../../app/services/cwmp/main_state_machine.js' +import { Device } from '../../../../app/domain/device/device.js' +import { DeviceId } from '../../../../app/domain/device/device_id.js' +import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js' + +function makeDevice(dataModel: string = 'tr-181') { + const d = new Device(new DeviceId('Mfr', 'OUI', 'PC', 'SN')) + d.setDataModel(dataModel as any) + // Simulate Inform params (tmpParamValues) so IdleState can detect unclassified params + const root = dataModel === 'tr-098' ? 'InternetGatewayDevice' : 'Device' + d.setTmpParamValues([ + { Name: `${root}.ManagementServer.ConnectionRequestURL`, Value: { _: 'http://cpe/' } }, + ]) + return d +} + +describe('MainStateMachine', () => { + it('starts in idle state', () => { + const sm = new MainStateMachine(makeDevice(), 'sess-1', new CWMPTaskQueue()) + expect(sm.getCurrentStateName()).toBe('idle') + expect(sm.isCompleted()).toBe(false) + }) + + it('start triggers state execution and returns response', async () => { + const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) + const response = await sm.start() + // idle → discovering-parameters → sends GPN for Device.ManagementServer. (single test path) + expect(response).toContain('GetParameterNames') + expect(response).toContain('Device.ManagementServer.') + expect(sm.getCurrentStateName()).toBe('discovering-parameters') + }) + + it('handleResponse advances the state machine', async () => { + const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) + await sm.start() + // Simulate GPN success response → transitions to critical-reads (single path queue) + await sm.handleResponse(true, { + ParameterList: { + ParameterInfoStruct: [{ Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: '1' }], + }, + }) + // Only one path in queue → transitions to critical-reads + expect(sm.getCurrentStateName()).toBe('critical-reads') + }) + + it('with unknown data model and no unclassified params, skips to critical-reads', async () => { + const device = new Device(new DeviceId('Mfr', 'OUI', 'PC', 'SN')) + device.setDataModel('unknown' as any) + // No tmpParamValues → nothing to classify → skips to critical-reads + const sm = new MainStateMachine(device, 'sess-1', new CWMPTaskQueue()) + await sm.start() + expect(sm.getCurrentStateName()).toBe('critical-reads') + }) + + it('with tr-098, sends GPN for InternetGatewayDevice.ManagementServer.', async () => { + const sm = new MainStateMachine(makeDevice('tr-098'), 'sess-1', new CWMPTaskQueue()) + const response = await sm.start() + expect(response).toContain('InternetGatewayDevice.ManagementServer.') + }) + + it('GPN failure sets gpnStatus unsupported and continues', async () => { + const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) + await sm.start() + // Simulate GPN fault (9001 = method not supported) + await sm.handleResponse(false, { FaultCode: 9001, FaultString: 'Method not supported' }) + // Should have transitioned past discovering-parameters + expect(sm.getCurrentStateName()).not.toBe('discovering-parameters') + }) + + it('full lifecycle: idle → discovering → critical-reads → config-eval → compliance → executing → operational', async () => { + const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) + // Step 1: start → GPN (single path test) + await sm.start() + expect(sm.getCurrentStateName()).toBe('discovering-parameters') + + // Step 2: GPN response → transitions to critical-reads + await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } }) + expect(sm.getCurrentStateName()).toBe('critical-reads') + + // Step 3+: critical reads will send GPV, after response moves to config eval + // Simulate multiple responses to drain the state machine + let guard = 0 + while (!sm.isCompleted() && guard++ < 20) { + await sm.handleResponse(true, { + Status: 0, + ParameterList: { ParameterInfoStruct: [], ParameterValueStruct: [] }, + }) + } + expect(sm.isCompleted()).toBe(true) + }) + + it('error in state transitions to completed gracefully', async () => { + const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) + await sm.start() + // Force an unexpected error by passing malformed data many times + for (let i = 0; i < 20; i++) { + await sm.handleResponse(false, { error: 'unexpected' }) + if (sm.isCompleted()) break + } + // Should eventually complete (safety guard or error handling) + expect(sm.isCompleted()).toBe(true) + }) +}) + +describe('ExecutingCommandsState', () => { + it('returns isComplete when no commands found (includeNI=true)', async () => { + const state = new ExecutingCommandsState(true) + const device = makeDevice() + const context = { + device, + session: 'sess-1', + taskQueue: new CWMPTaskQueue(), + services: { deviceAction: { getParameterValues: vi.fn() } as any }, + } + const result = await state.handle(context) + // With includeNI=true and no commands, transitions to OperationalState + expect(result.nextState).toBeDefined() + expect(result.nextState!.name).toBe('operational') + }) + + it('returns isComplete when no commands found (includeNI=false)', async () => { + const state = new ExecutingCommandsState(false) + const device = makeDevice() + const context = { + device, + session: 'sess-1', + taskQueue: new CWMPTaskQueue(), + services: { deviceAction: {} as any }, + } + const result = await state.handle(context) + expect(result.isComplete).toBe(true) + }) +}) + + +describe('MainStateMachine — additional coverage (DiscoveringParameters, GPN flows)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('sends GPN for tr-098 device when gpnStatus=unknown', async () => { + const device = new Device(new DeviceId('Huawei', '202BC1', 'BM632w', 'SN001')) + device.setDataModel('tr-098') + device.setGpnStatus('unknown') + device.setTmpParamValues([{ Name: 'InternetGatewayDevice.ManagementServer.URL', Value: { _: 'http://acs/' } }]) + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'sess', taskQueue) + + const response = await sm.start() + expect(response).toContain('GetParameterNames') + expect(response).toContain('InternetGatewayDevice.ManagementServer.') + }) + + it('skips GPN for unknown dataModel and goes to critical-reads', async () => { + const device = new Device(new DeviceId('Unknown', '000000', 'Generic', 'SN002')) + device.setDataModel('unknown') + device.setGpnStatus('unknown') + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'sess', taskQueue) + + const response = await sm.start() + // Should go to critical-reads (GPV) since unknown data model skips GPN + expect(response).toContain('GetParameterValues') + }) + + it('DiscoveringParametersState handles GPN success and transitions to CriticalReads', async () => { + const device = makeDevice('unknown') // gpnStatus=unknown → will attempt GPN + device.setGpnStatus('unknown') + device.setDataModel('tr-181') + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'sess', taskQueue) + + // Start: sends GPN for Device.ManagementServer. (single test path) + await sm.start() + + // GPN response → transitions to critical-reads and sends GPV + const response = await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } }) + expect(response).toContain('GetParameterValues') + expect(sm.getCurrentStateName()).toBe('critical-reads') + }) + + it('handles MAX_STATE_TRANSITIONS exceeded gracefully', async () => { + // Create a state machine with a device that will loop + const device = makeDevice('unsupported') + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'sess', taskQueue) + + // The machine won't actually loop 15 times in normal flow since it completes + // Just verify the machine starts and runs without crashing + const response = await sm.start() + expect(response).toBeDefined() + }) +}) + +describe('OperationalState', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('persists device with operational status', async () => { + const device = makeDevice() + const taskQueue = new CWMPTaskQueue() + const context = { + device, + session: 'test-session', + taskQueue, + services: { deviceAction: {} as any }, + } + + const state = new OperationalState() + const result = await state.handle(context as any) + + expect(result.isComplete).toBe(true) + expect(device.operationalStatus).toBe('operational') + expect(vi.mocked(DeviceModel.createOrUpdate)).toHaveBeenCalledWith( + expect.objectContaining({ operationalStatus: 'operational' }) + ) + }) +}) + +describe('PartialOperationalState', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('persists device with partial_operational status and failure reason', async () => { + const device = makeDevice() + const taskQueue = new CWMPTaskQueue() + const context = { + device, + session: 'test-session', + taskQueue, + services: { deviceAction: {} as any }, + } + + const state = new PartialOperationalState(device, { + reason: 'test_failure', + blockingState: 'some-state', + }) + const result = await state.handle(context as any) + + expect(result.isComplete).toBe(true) + expect(device.operationalStatus).toBe('partial_operational') + expect(device.lastFailureReason).toBe('test_failure') + expect(vi.mocked(DeviceModel.createOrUpdate)).toHaveBeenCalledWith( + expect.objectContaining({ + operationalStatus: 'partial_operational', + lastFailureReason: 'test_failure', + }) + ) + }) +}) + + +describe('main_state_machine.ts — branch/function coverage', () => { + beforeEach(() => { vi.clearAllMocks() }) + + // --- DiscoveringParametersState: empty queue (line 124) --- + it('DiscoveringParametersState with empty queue transitions to CriticalReads', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + device.setTmpParamValues([{ Name: 'Device.X', Value: { _: '1' } }]) + // Manually create with empty path array (edge case) + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'sess', taskQueue) + + // The paths from tmpParams should produce at least 'Device.' — test passes if GPN is sent + const response = await sm.start() + expect(response).toContain('GetParameterNames') + }) + + // --- DiscoveringParametersState: persist error path (line 166) --- + it('DiscoveringParametersState handles persist error gracefully', async () => { + vi.mocked(DeviceModel.createOrUpdate).mockRejectedValueOnce(new Error('DB timeout')) + const device = makeDevice() + device.setGpnStatus('unknown') + device.setTmpParamValues([{ Name: 'Device.ManagementServer.URL', Value: { _: 'http://x' } }]) + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'sess', taskQueue) + + await sm.start() // sends GPN + // Simulate GPN fault — persist will fail + const response = await sm.handleResponse(false, { FaultCode: 9001 }) + + // Should still transition to critical-reads despite persist error + expect(response).toContain('GetParameterValues') + expect(vi.mocked(logger.error)).toHaveBeenCalled() + }) + + // --- CriticalReadsState: error in updateFromValues (line 258) --- + it('CriticalReadsState catches error in GPV response processing', async () => { + const device = makeDevice() + device.setTmpParamValues([]) // no unclassified → skips to CriticalReads + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'sess', taskQueue) + + const step1 = await sm.start() // sends GPV (critical reads) + expect(step1).toContain('GetParameterValues') + + // Force updateFromValues to throw + vi.spyOn(device, 'updateFromValues').mockImplementationOnce(() => { throw new Error('parse crash') }) + const step2 = await sm.handleResponse(true, { + ParameterList: { ParameterValueStruct: [{ Name: 'Device.X', Value: { _: 'v' } }] }, + }) + + // Should still transition to config-eval despite error + expect(vi.mocked(logger.error)).toHaveBeenCalled() + }) + + // --- ExecutingCommandsState: GET_PARAMETER_ATTRIBUTES (line 373) --- + it('ExecutingCommandsState sends GetParameterAttributes', async () => { + setupCommandMock([{ _id: 'c1', action: CommandType.GET_PARAMETER_ATTRIBUTES, parameters: ['Device.X.'], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) + const result = await new ExecutingCommandsState(true).handle(makeContext(makeDevice())) + expect(result.response).toContain('GetParameterAttributes') + }) + + // --- ExecutingCommandsState: SCHEDULE_INFORM (line 429) --- + it('ExecutingCommandsState sends ScheduleInform', async () => { + setupCommandMock([{ _id: 'c2', action: CommandType.SCHEDULE_INFORM, parameters: { delayInSeconds: 600, commandKey: 'sched-1' }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) + const result = await new ExecutingCommandsState(true).handle(makeContext(makeDevice())) + expect(result.response).toContain('ScheduleInform') + expect(result.response).toContain('600') + }) + + // --- ExecutingCommandsState: REFRESH with no params (line 441 empty case) --- + it('ExecutingCommandsState REFRESH with no known params returns empty', async () => { + setupCommandMock([{ _id: 'c3', action: CommandType.REFRESH, parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) + const device = makeDevice() + // Device has no params/props with values → allNames empty + const context = makeContext(device) + const state = new ExecutingCommandsState(true) + const result = await state.handle(context) + // REFRESH with no known params → returns empty, transitions to operational + expect(result.nextState || result.response === '').toBeTruthy() + }) + + // --- ExecutingCommandsState: default (unknown) with remaining commands in queue (line 450) --- + it('ExecutingCommandsState unknown type with more commands processes next', async () => { + const collection = CommandModel.getCollection() + let callCount = 0 + vi.mocked(collection.find).mockImplementation(() => ({ + sort: () => ({ + toArray: () => { + if (callCount++ === 0) return Promise.resolve([ + { _id: 'u1', action: 'UNKNOWN_TYPE', parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }, + { _id: 'u2', action: CommandType.REBOOT, parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 2 }, + ]) + return Promise.resolve([]) + }, + }), + } as any)) + + const state = new ExecutingCommandsState(true) + const result = await state.handle(makeContext(makeDevice())) + // First command is unknown (marked failed), second is Reboot (processed) + expect(result.response).toContain('Reboot') + }) + + // --- OperationalState: persist error (line 486) --- + it('OperationalState handles persist error gracefully', async () => { + vi.mocked(DeviceModel.createOrUpdate).mockRejectedValueOnce(new Error('DB down')) + const device = makeDevice() + const context = makeContext(device) + const state = new OperationalState() + + const result = await state.handle(context as any) + expect(result.isComplete).toBe(true) + expect(device.operationalStatus).toBe('operational') + expect(vi.mocked(logger.error)).toHaveBeenCalled() + }) + + // --- Execute task callbacks to cover anonymous functions --- + + it('CriticalReadsState task callback executes without error (line 229)', async () => { + const device = makeDevice() + device.setTmpParamValues([]) + const context = makeContext(device) + const sm = new MainStateMachine(device, 'sess-cb', context.taskQueue) + + await sm.start() // sends GPV (critical reads) + // Execute the registered task callback + const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys())[0] + if (taskId) { + const result = await context.taskQueue.executeTaskCallback(taskId, 'success' as any, { + ParameterList: { ParameterValueStruct: [{ Name: 'Device.X', Value: { _: 'v' } }] }, + }) + expect(result).toBe('') + } + }) + + it('ExecutingCommandsState GET_PARAMETER_ATTRIBUTES callback executes', async () => { + setupCommandMock([{ _id: 'cb1', action: CommandType.GET_PARAMETER_ATTRIBUTES, parameters: ['Device.X.'], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) + const context = makeContext(makeDevice()) + const state = new ExecutingCommandsState(true) + await state.handle(context) + const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys()).pop() + if (taskId) { + const result = await context.taskQueue.executeTaskCallback(taskId, 'success' as any, {}) + expect(result).toBe('') + } + }) + + it('ExecutingCommandsState SCHEDULE_INFORM callback executes', async () => { + setupCommandMock([{ _id: 'cb2', action: CommandType.SCHEDULE_INFORM, parameters: { delayInSeconds: 300 }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) + const context = makeContext(makeDevice()) + const state = new ExecutingCommandsState(true) + await state.handle(context) + const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys()).pop() + if (taskId) { + const result = await context.taskQueue.executeTaskCallback(taskId, 'success' as any, {}) + expect(result).toBe('') + } + }) + + it('ExecutingCommandsState REFRESH callback executes', async () => { + setupCommandMock([{ _id: 'cb3', action: CommandType.REFRESH, parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) + const device = makeDevice() + device.setParameter(new DeviceParameter('Device.X.Val', 'test', ParameterType.STRING)) + const context = makeContext(device) + const state = new ExecutingCommandsState(true) + await state.handle(context) + const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys()).pop() + if (taskId) { + const result = await context.taskQueue.executeTaskCallback(taskId, 'success' as any, {}) + expect(result).toBe('') + } + }) + + it('ExecutingCommandsState SET_PARAMETER_VALUES callback executes', async () => { + setupCommandMock([{ _id: 'cb4', action: CommandType.SET_PARAMETER_VALUES, parameters: { parameterValues: [{ name: 'Device.X', value: '1' }] }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) + const context = makeContext(makeDevice()) + const state = new ExecutingCommandsState(true) + await state.handle(context) + const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map + const taskId = Array.from(taskCallbacks.keys()).pop() + if (taskId) { + const result = await context.taskQueue.executeTaskCallback(taskId, 'success' as any, {}) + expect(result).toBe('') + } + }) + + // --- PartialOperationalState: persist error (line 530) --- + + // --- PartialOperationalState: persist error (line 530) --- + it('PartialOperationalState handles persist error gracefully', async () => { + vi.mocked(DeviceModel.createOrUpdate).mockRejectedValueOnce(new Error('DB down')) + const device = makeDevice() + const context = makeContext(device) + const state = new PartialOperationalState(device, { reason: 'test', blockingState: 'x' }) + + const result = await state.handle(context as any) + expect(result.isComplete).toBe(true) + expect(device.operationalStatus).toBe('partial_operational') + expect(vi.mocked(logger.error)).toHaveBeenCalled() + }) + + // --- executeCurrentState: error in state handler (lines 605-616) --- + it('MainStateMachine transitions to PartialOperational on unrecoverable state error', async () => { + const device = makeDevice() + device.setTmpParamValues([{ Name: 'Device.X', Value: { _: '1' } }]) + device.setGpnStatus('unknown') + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'sess', taskQueue) + + await sm.start() // sends GPN + + // Mock the internal state to throw on next handle + ;(sm as any).currentState = { + name: 'broken-state', + handle: async () => { throw new Error('catastrophic failure') }, + } + + await sm.handleResponse(true, {}) + + // Should be in partial_operational + expect(sm.getCurrentStateName()).toBe('partial_operational') + expect(sm.isCompleted()).toBe(true) + }) + + // --- executeCurrentState: max transitions exceeded (lines 619-632) --- + it('MainStateMachine transitions to PartialOperational on max transitions exceeded', async () => { + const device = makeDevice() + device.setTmpParamValues([{ Name: 'Device.X', Value: { _: '1' } }]) + device.setGpnStatus('unknown') + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'sess', taskQueue) + + await sm.start() + + // Create a self-referencing looping state that always returns a new nextState + const makeLooper: any = () => ({ + name: 'looper', + handle: async () => ({ nextState: makeLooper(), response: '' }), + }) + ;(sm as any).currentState = makeLooper() + + await sm.handleResponse(true, {}) + + // After 15 transitions, should be partial_operational + expect(sm.getCurrentStateName()).toBe('partial_operational') + expect(sm.isCompleted()).toBe(true) + }) +}) + + +describe('CriticalReadsState — GPV response processing', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('processes GPV response and updates device values', async () => { + const device = makeDevice() + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-1', taskQueue) + + // Start: IdleState → CriticalReadsState → sends GPV + const gpvRequest = await sm.start() + expect(gpvRequest).toContain('GetParameterValues') + + // Simulate GPVResponse with ParameterValueStruct + await sm.handleResponse(true, { + ParameterList: { + ParameterValueStruct: [ + { Name: 'Device.DeviceInfo.HardwareVersion', Value: { _: 'v3.0', '$xsi:type': 'xsd:string' } }, + { Name: 'Device.DeviceInfo.SoftwareVersion', Value: { _: 'SR50-5.0.0', '$xsi:type': 'xsd:string' } }, + { Name: 'Device.DeviceInfo.ModelName', Value: { _: 'Livebox 4', '$xsi:type': 'xsd:string' } }, + ], + }, + }) + + // Device should have the values stored + const hwProp = device.getProperty('Device.DeviceInfo.HardwareVersion') + expect(hwProp).not.toBeNull() + expect(hwProp!.value).toBe('v3.0') + + const swProp = device.getProperty('Device.DeviceInfo.SoftwareVersion') + expect(swProp).not.toBeNull() + expect(swProp!.value).toBe('SR50-5.0.0') + }) + + it('normalizes Name fields from object to string when xsi:type attribute is present', async () => { + const device = makeDevice() + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-1', taskQueue) + + await sm.start() + + // Simulate GPVResponse where Name has xsi:type attribute (parsed as object by XMLParser) + await sm.handleResponse(true, { + ParameterList: { + ParameterValueStruct: [ + { + Name: { _: 'Device.DeviceInfo.HardwareVersion', '$xsi:type': 'xsd:string' }, + Value: { _: 'v2.1', '$xsi:type': 'xsd:string' }, + }, + ], + }, + }) + + // Should normalize Name._ to string and store correctly + const hwProp = device.getProperty('Device.DeviceInfo.HardwareVersion') + expect(hwProp).not.toBeNull() + expect(hwProp!.value).toBe('v2.1') + }) + + it('handles empty ParameterValueStruct gracefully', async () => { + const device = makeDevice() + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-1', taskQueue) + + await sm.start() + + // Simulate empty GPV response + await sm.handleResponse(true, { ParameterList: { ParameterValueStruct: [] } }) + + // Should not throw, machine should continue + expect(sm.getCurrentStateName()).not.toBe('critical-reads') + }) + + it('handles missing ParameterList gracefully', async () => { + const device = makeDevice() + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-1', taskQueue) + + await sm.start() + + // Simulate response with no ParameterList + await sm.handleResponse(true, {}) + + // Should not throw, machine should continue + expect(sm.getCurrentStateName()).not.toBe('critical-reads') + }) + + it('transitions to configuration-evaluation after processing', async () => { + const device = makeDevice() + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-1', taskQueue) + + await sm.start() + await sm.handleResponse(true, { + ParameterList: { + ParameterValueStruct: [ + { Name: 'Device.DeviceInfo.HardwareVersion', Value: { _: 'v1', '$xsi:type': 'xsd:string' } }, + ], + }, + }) + + // Should have transitioned past critical-reads to configuration-evaluation + expect(sm.getCurrentStateName()).not.toBe('critical-reads') + expect(sm.getCurrentStateName()).toBe('configuration-evaluation') + }) +}) + + +describe('GPN Circuit Breaker — DiscoveringParametersState', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('IdleState skips GPN when gpnStatus is unsupported', async () => { + const device = makeDevice() + device.setGpnStatus('unsupported') + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-1', taskQueue) + + // Start triggers IdleState → should skip GPN + const response = await sm.start() + + // Should produce a GPV (critical reads) not a GPN + expect(response).toContain('GetParameterValues') + expect(response).not.toContain('GetParameterNames') + }) + + it('IdleState skips GPN when circuit breaker is open', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + device.setGpnCircuitBreaker({ + failureCount: 2, + lastFailureDate: new Date(), + state: 'open', + cooldownUntil: new Date(Date.now() + 86400000), + }) + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-1', taskQueue) + + const response = await sm.start() + + // Should produce a GPV (critical reads) not a GPN + expect(response).toContain('GetParameterValues') + expect(response).not.toContain('GetParameterNames') + }) + + it('IdleState proceeds to GPN when gpnStatus is unknown and circuit breaker is closed', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-1', taskQueue) + + const response = await sm.start() + + // Should produce a GPN request + expect(response).toContain('GetParameterNames') + }) + + it('DiscoveringParametersState processes GPN response and stores parameters', async () => { + const device = makeDevice() // tr-181 + device.setGpnStatus('unknown') + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-1', taskQueue) + + // Start: IdleState → DiscoveringParametersState → sends GPN for Device.ManagementServer. + await sm.start() + + // Simulate a successful GPN response → transitions to critical-reads + const gpnResponse = { + ParameterList: { + ParameterInfoStruct: [ + { Name: 'Device.ManagementServer.', Writable: false }, + { Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: true }, + { Name: 'Device.ManagementServer.ConnectionRequestURL', Writable: false }, + ], + }, + } + await sm.handleResponse(true, gpnResponse) + + // Should transition to critical-reads after single GPN + expect(sm.getCurrentStateName()).toBe('critical-reads') + // Device should be marked as GPN supported + expect(device.gpnStatus).toBe('supported') + }) + + it('DiscoveringParametersState sets gpnStatus supported even with empty param list', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-1', taskQueue) + + await sm.start() + + // Respond with empty list + await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } }) + + expect(device.gpnStatus).toBe('supported') + }) + + it('DiscoveringParametersState calls recordGpnFailure and persists on fault', async () => { + const device = makeDevice() + device.setGpnStatus('unknown') + const taskQueue = new CWMPTaskQueue() + const sm = new MainStateMachine(device, 'session-1', taskQueue) + + // Start: IdleState → DiscoveringParametersState → sends GPN + await sm.start() + + // Simulate CPE fault response + const response = await sm.handleResponse(false, { FaultCode: '9001', FaultString: 'Method not supported' }) + + // Device should now be marked as unsupported + expect(device.gpnStatus).toBe('unsupported') + + // Circuit breaker should have recorded the failure + expect(device.gpnCircuitBreaker.failureCount).toBe(1) + + // Should have persisted via createOrUpdate + expect(DeviceModel.createOrUpdate).toHaveBeenCalled() + + // Should continue to critical reads (GPV) + expect(response).toContain('GetParameterValues') + }) +}) + diff --git a/tests/unit/services/cwmp/main_state_machine_branches.spec.ts b/tests/unit/services/cwmp/main_state_machine_branches.spec.ts deleted file mode 100644 index 09d972f..0000000 --- a/tests/unit/services/cwmp/main_state_machine_branches.spec.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// Mock logger -vi.mock('@adonisjs/core/services/logger', () => ({ - default: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, -})) - -// Mock DeviceModel -vi.mock('../../../../app/models/device.js', () => ({ - default: { - createOrUpdate: vi.fn().mockResolvedValue({}), - findByDeviceId: vi.fn().mockResolvedValue(null), - updateOne: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock CommandModel -vi.mock('../../../../app/models/command.js', () => ({ - default: { - getCollection: vi.fn().mockReturnValue({ - find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), - countDocuments: vi.fn().mockResolvedValue(0), - }), - updateCommand: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock ConfigurationEvaluationState -vi.mock('../../../../app/services/cwmp/configuration_evaluation_state.js', () => ({ - ConfigurationEvaluationState: class { - name = 'configuration-evaluation' - async handle() { return { isComplete: true, response: '' } } - }, -})) - -// Mock reclassify -vi.mock('../../../../app/services/parameter_writability.js', () => ({ - reclassifyDeviceFromCatalog: vi.fn(), -})) - -import { MainStateMachine, OperationalState, PartialOperationalState, ExecutingCommandsState } from '../../../../app/services/cwmp/main_state_machine.js' -import { Device } from '../../../../app/domain/device/device.js' -import { DeviceId } from '../../../../app/domain/device/device_id.js' -import { DeviceParameter, DeviceProperty } from '../../../../app/domain/device/device_parameter.js' -import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js' -import { DeviceActionExecutor } from '../../../../app/services/cwmp/device_action_executor.js' -import { CommandStatus, CommandType } from '../../../../app/domain/commands/command.js' -import { ModeType } from '../../../../app/domain/shared/mode.js' -import { ParameterType } from '../../../../app/domain/shared/types.js' -import DeviceModel from '../../../../app/models/device.js' -import CommandModel from '../../../../app/models/command.js' -import logger from '@adonisjs/core/services/logger' - -function makeDevice(): Device { - const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) - device.setDataModel('tr-181') - device.setGpnStatus('unsupported') - return device -} - -function makeContext(device: Device) { - const taskQueue = new CWMPTaskQueue() - return { - device, - session: 'test-session', - message: undefined as any, - taskQueue, - services: { deviceAction: new DeviceActionExecutor(taskQueue) }, - } -} - -function setupCommandMock(commands: any[]) { - const collection = CommandModel.getCollection() - let callCount = 0 - vi.mocked(collection.find).mockImplementation(() => ({ - sort: () => ({ - toArray: () => { - const result = callCount === 0 ? commands : [] - callCount++ - return Promise.resolve(result) - }, - }), - } as any)) -} - -describe('main_state_machine.ts — branch/function coverage', () => { - beforeEach(() => { vi.clearAllMocks() }) - - // --- DiscoveringParametersState: empty queue (line 124) --- - it('DiscoveringParametersState with empty queue transitions to CriticalReads', async () => { - const device = makeDevice() - device.setGpnStatus('unknown') - device.setTmpParamValues([{ Name: 'Device.X', Value: { _: '1' } }]) - // Manually create with empty path array (edge case) - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'sess', taskQueue) - - // The paths from tmpParams should produce at least 'Device.' — test passes if GPN is sent - const response = await sm.start() - expect(response).toContain('GetParameterNames') - }) - - // --- DiscoveringParametersState: persist error path (line 166) --- - it('DiscoveringParametersState handles persist error gracefully', async () => { - vi.mocked(DeviceModel.createOrUpdate).mockRejectedValueOnce(new Error('DB timeout')) - const device = makeDevice() - device.setGpnStatus('unknown') - device.setTmpParamValues([{ Name: 'Device.ManagementServer.URL', Value: { _: 'http://x' } }]) - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'sess', taskQueue) - - await sm.start() // sends GPN - // Simulate GPN fault — persist will fail - const response = await sm.handleResponse(false, { FaultCode: 9001 }) - - // Should still transition to critical-reads despite persist error - expect(response).toContain('GetParameterValues') - expect(vi.mocked(logger.error)).toHaveBeenCalled() - }) - - // --- CriticalReadsState: error in updateFromValues (line 258) --- - it('CriticalReadsState catches error in GPV response processing', async () => { - const device = makeDevice() - device.setTmpParamValues([]) // no unclassified → skips to CriticalReads - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'sess', taskQueue) - - const step1 = await sm.start() // sends GPV (critical reads) - expect(step1).toContain('GetParameterValues') - - // Force updateFromValues to throw - vi.spyOn(device, 'updateFromValues').mockImplementationOnce(() => { throw new Error('parse crash') }) - const step2 = await sm.handleResponse(true, { - ParameterList: { ParameterValueStruct: [{ Name: 'Device.X', Value: { _: 'v' } }] }, - }) - - // Should still transition to config-eval despite error - expect(vi.mocked(logger.error)).toHaveBeenCalled() - }) - - // --- ExecutingCommandsState: GET_PARAMETER_ATTRIBUTES (line 373) --- - it('ExecutingCommandsState sends GetParameterAttributes', async () => { - setupCommandMock([{ _id: 'c1', action: CommandType.GET_PARAMETER_ATTRIBUTES, parameters: ['Device.X.'], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) - const result = await new ExecutingCommandsState(true).handle(makeContext(makeDevice())) - expect(result.response).toContain('GetParameterAttributes') - }) - - // --- ExecutingCommandsState: SCHEDULE_INFORM (line 429) --- - it('ExecutingCommandsState sends ScheduleInform', async () => { - setupCommandMock([{ _id: 'c2', action: CommandType.SCHEDULE_INFORM, parameters: { delayInSeconds: 600, commandKey: 'sched-1' }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) - const result = await new ExecutingCommandsState(true).handle(makeContext(makeDevice())) - expect(result.response).toContain('ScheduleInform') - expect(result.response).toContain('600') - }) - - // --- ExecutingCommandsState: REFRESH with no params (line 441 empty case) --- - it('ExecutingCommandsState REFRESH with no known params returns empty', async () => { - setupCommandMock([{ _id: 'c3', action: CommandType.REFRESH, parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) - const device = makeDevice() - // Device has no params/props with values → allNames empty - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - const result = await state.handle(context) - // REFRESH with no known params → returns empty, transitions to operational - expect(result.nextState || result.response === '').toBeTruthy() - }) - - // --- ExecutingCommandsState: default (unknown) with remaining commands in queue (line 450) --- - it('ExecutingCommandsState unknown type with more commands processes next', async () => { - const collection = CommandModel.getCollection() - let callCount = 0 - vi.mocked(collection.find).mockImplementation(() => ({ - sort: () => ({ - toArray: () => { - if (callCount++ === 0) return Promise.resolve([ - { _id: 'u1', action: 'UNKNOWN_TYPE', parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }, - { _id: 'u2', action: CommandType.REBOOT, parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 2 }, - ]) - return Promise.resolve([]) - }, - }), - } as any)) - - const state = new ExecutingCommandsState(true) - const result = await state.handle(makeContext(makeDevice())) - // First command is unknown (marked failed), second is Reboot (processed) - expect(result.response).toContain('Reboot') - }) - - // --- OperationalState: persist error (line 486) --- - it('OperationalState handles persist error gracefully', async () => { - vi.mocked(DeviceModel.createOrUpdate).mockRejectedValueOnce(new Error('DB down')) - const device = makeDevice() - const context = makeContext(device) - const state = new OperationalState() - - const result = await state.handle(context as any) - expect(result.isComplete).toBe(true) - expect(device.operationalStatus).toBe('operational') - expect(vi.mocked(logger.error)).toHaveBeenCalled() - }) - - // --- Execute task callbacks to cover anonymous functions --- - - it('CriticalReadsState task callback executes without error (line 229)', async () => { - const device = makeDevice() - device.setTmpParamValues([]) - const context = makeContext(device) - const sm = new MainStateMachine(device, 'sess-cb', context.taskQueue) - - await sm.start() // sends GPV (critical reads) - // Execute the registered task callback - const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys())[0] - if (taskId) { - const result = await context.taskQueue.executeTaskCallback(taskId, 'success' as any, { - ParameterList: { ParameterValueStruct: [{ Name: 'Device.X', Value: { _: 'v' } }] }, - }) - expect(result).toBe('') - } - }) - - it('ExecutingCommandsState GET_PARAMETER_ATTRIBUTES callback executes', async () => { - setupCommandMock([{ _id: 'cb1', action: CommandType.GET_PARAMETER_ATTRIBUTES, parameters: ['Device.X.'], status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) - const context = makeContext(makeDevice()) - const state = new ExecutingCommandsState(true) - await state.handle(context) - const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys()).pop() - if (taskId) { - const result = await context.taskQueue.executeTaskCallback(taskId, 'success' as any, {}) - expect(result).toBe('') - } - }) - - it('ExecutingCommandsState SCHEDULE_INFORM callback executes', async () => { - setupCommandMock([{ _id: 'cb2', action: CommandType.SCHEDULE_INFORM, parameters: { delayInSeconds: 300 }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) - const context = makeContext(makeDevice()) - const state = new ExecutingCommandsState(true) - await state.handle(context) - const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys()).pop() - if (taskId) { - const result = await context.taskQueue.executeTaskCallback(taskId, 'success' as any, {}) - expect(result).toBe('') - } - }) - - it('ExecutingCommandsState REFRESH callback executes', async () => { - setupCommandMock([{ _id: 'cb3', action: CommandType.REFRESH, parameters: {}, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) - const device = makeDevice() - device.setParameter(new DeviceParameter('Device.X.Val', 'test', ParameterType.STRING)) - const context = makeContext(device) - const state = new ExecutingCommandsState(true) - await state.handle(context) - const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys()).pop() - if (taskId) { - const result = await context.taskQueue.executeTaskCallback(taskId, 'success' as any, {}) - expect(result).toBe('') - } - }) - - it('ExecutingCommandsState SET_PARAMETER_VALUES callback executes', async () => { - setupCommandMock([{ _id: 'cb4', action: CommandType.SET_PARAMETER_VALUES, parameters: { parameterValues: [{ name: 'Device.X', value: '1' }] }, status: CommandStatus.PENDING, mode: ModeType.I, priority: 1 }]) - const context = makeContext(makeDevice()) - const state = new ExecutingCommandsState(true) - await state.handle(context) - const taskCallbacks = (context.taskQueue as any).taskCallbacks as Map - const taskId = Array.from(taskCallbacks.keys()).pop() - if (taskId) { - const result = await context.taskQueue.executeTaskCallback(taskId, 'success' as any, {}) - expect(result).toBe('') - } - }) - - // --- PartialOperationalState: persist error (line 530) --- - - // --- PartialOperationalState: persist error (line 530) --- - it('PartialOperationalState handles persist error gracefully', async () => { - vi.mocked(DeviceModel.createOrUpdate).mockRejectedValueOnce(new Error('DB down')) - const device = makeDevice() - const context = makeContext(device) - const state = new PartialOperationalState(device, { reason: 'test', blockingState: 'x' }) - - const result = await state.handle(context as any) - expect(result.isComplete).toBe(true) - expect(device.operationalStatus).toBe('partial_operational') - expect(vi.mocked(logger.error)).toHaveBeenCalled() - }) - - // --- executeCurrentState: error in state handler (lines 605-616) --- - it('MainStateMachine transitions to PartialOperational on unrecoverable state error', async () => { - const device = makeDevice() - device.setTmpParamValues([{ Name: 'Device.X', Value: { _: '1' } }]) - device.setGpnStatus('unknown') - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'sess', taskQueue) - - await sm.start() // sends GPN - - // Mock the internal state to throw on next handle - ;(sm as any).currentState = { - name: 'broken-state', - handle: async () => { throw new Error('catastrophic failure') }, - } - - await sm.handleResponse(true, {}) - - // Should be in partial_operational - expect(sm.getCurrentStateName()).toBe('partial_operational') - expect(sm.isCompleted()).toBe(true) - }) - - // --- executeCurrentState: max transitions exceeded (lines 619-632) --- - it('MainStateMachine transitions to PartialOperational on max transitions exceeded', async () => { - const device = makeDevice() - device.setTmpParamValues([{ Name: 'Device.X', Value: { _: '1' } }]) - device.setGpnStatus('unknown') - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'sess', taskQueue) - - await sm.start() - - // Create a self-referencing looping state that always returns a new nextState - const makeLooper: any = () => ({ - name: 'looper', - handle: async () => ({ nextState: makeLooper(), response: '' }), - }) - ;(sm as any).currentState = makeLooper() - - await sm.handleResponse(true, {}) - - // After 15 transitions, should be partial_operational - expect(sm.getCurrentStateName()).toBe('partial_operational') - expect(sm.isCompleted()).toBe(true) - }) -}) diff --git a/tests/unit/services/cwmp/main_state_machine_extended.spec.ts b/tests/unit/services/cwmp/main_state_machine_extended.spec.ts deleted file mode 100644 index c00cafc..0000000 --- a/tests/unit/services/cwmp/main_state_machine_extended.spec.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' - -vi.mock('@adonisjs/core/services/logger', () => ({ - default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, -})) -vi.mock('#models/device', () => ({ - default: { - createOrUpdate: vi.fn().mockResolvedValue({}), - updateOne: vi.fn().mockResolvedValue({}), - }, -})) -vi.mock('../../../../app/models/command.js', () => ({ - default: { - getCollection: () => ({ - find: vi.fn().mockReturnValue({ - sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }), - }), - }), - updateCommand: vi.fn().mockResolvedValue({}), - }, -})) - -import { - MainStateMachine, - ExecutingCommandsState, -} from '../../../../app/services/cwmp/main_state_machine.js' -import { Device } from '../../../../app/domain/device/device.js' -import { DeviceId } from '../../../../app/domain/device/device_id.js' -import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js' - -function makeDevice(dataModel: string = 'tr-181') { - const d = new Device(new DeviceId('Mfr', 'OUI', 'PC', 'SN')) - d.setDataModel(dataModel as any) - // Simulate Inform params (tmpParamValues) so IdleState can detect unclassified params - const root = dataModel === 'tr-098' ? 'InternetGatewayDevice' : 'Device' - d.setTmpParamValues([ - { Name: `${root}.ManagementServer.ConnectionRequestURL`, Value: { _: 'http://cpe/' } }, - ]) - return d -} - -describe('MainStateMachine', () => { - it('starts in idle state', () => { - const sm = new MainStateMachine(makeDevice(), 'sess-1', new CWMPTaskQueue()) - expect(sm.getCurrentStateName()).toBe('idle') - expect(sm.isCompleted()).toBe(false) - }) - - it('start triggers state execution and returns response', async () => { - const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) - const response = await sm.start() - // idle → discovering-parameters → sends GPN for Device.ManagementServer. (single test path) - expect(response).toContain('GetParameterNames') - expect(response).toContain('Device.ManagementServer.') - expect(sm.getCurrentStateName()).toBe('discovering-parameters') - }) - - it('handleResponse advances the state machine', async () => { - const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) - await sm.start() - // Simulate GPN success response → transitions to critical-reads (single path queue) - await sm.handleResponse(true, { - ParameterList: { - ParameterInfoStruct: [{ Name: 'Device.ManagementServer.PeriodicInformInterval', Writable: '1' }], - }, - }) - // Only one path in queue → transitions to critical-reads - expect(sm.getCurrentStateName()).toBe('critical-reads') - }) - - it('with unknown data model and no unclassified params, skips to critical-reads', async () => { - const device = new Device(new DeviceId('Mfr', 'OUI', 'PC', 'SN')) - device.setDataModel('unknown' as any) - // No tmpParamValues → nothing to classify → skips to critical-reads - const sm = new MainStateMachine(device, 'sess-1', new CWMPTaskQueue()) - await sm.start() - expect(sm.getCurrentStateName()).toBe('critical-reads') - }) - - it('with tr-098, sends GPN for InternetGatewayDevice.ManagementServer.', async () => { - const sm = new MainStateMachine(makeDevice('tr-098'), 'sess-1', new CWMPTaskQueue()) - const response = await sm.start() - expect(response).toContain('InternetGatewayDevice.ManagementServer.') - }) - - it('GPN failure sets gpnStatus unsupported and continues', async () => { - const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) - await sm.start() - // Simulate GPN fault (9001 = method not supported) - await sm.handleResponse(false, { FaultCode: 9001, FaultString: 'Method not supported' }) - // Should have transitioned past discovering-parameters - expect(sm.getCurrentStateName()).not.toBe('discovering-parameters') - }) - - it('full lifecycle: idle → discovering → critical-reads → config-eval → compliance → executing → operational', async () => { - const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) - // Step 1: start → GPN (single path test) - await sm.start() - expect(sm.getCurrentStateName()).toBe('discovering-parameters') - - // Step 2: GPN response → transitions to critical-reads - await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } }) - expect(sm.getCurrentStateName()).toBe('critical-reads') - - // Step 3+: critical reads will send GPV, after response moves to config eval - // Simulate multiple responses to drain the state machine - let guard = 0 - while (!sm.isCompleted() && guard++ < 20) { - await sm.handleResponse(true, { - Status: 0, - ParameterList: { ParameterInfoStruct: [], ParameterValueStruct: [] }, - }) - } - expect(sm.isCompleted()).toBe(true) - }) - - it('error in state transitions to completed gracefully', async () => { - const sm = new MainStateMachine(makeDevice('tr-181'), 'sess-1', new CWMPTaskQueue()) - await sm.start() - // Force an unexpected error by passing malformed data many times - for (let i = 0; i < 20; i++) { - await sm.handleResponse(false, { error: 'unexpected' }) - if (sm.isCompleted()) break - } - // Should eventually complete (safety guard or error handling) - expect(sm.isCompleted()).toBe(true) - }) -}) - -describe('ExecutingCommandsState', () => { - it('returns isComplete when no commands found (includeNI=true)', async () => { - const state = new ExecutingCommandsState(true) - const device = makeDevice() - const context = { - device, - session: 'sess-1', - taskQueue: new CWMPTaskQueue(), - services: { deviceAction: { getParameterValues: vi.fn() } as any }, - } - const result = await state.handle(context) - // With includeNI=true and no commands, transitions to OperationalState - expect(result.nextState).toBeDefined() - expect(result.nextState!.name).toBe('operational') - }) - - it('returns isComplete when no commands found (includeNI=false)', async () => { - const state = new ExecutingCommandsState(false) - const device = makeDevice() - const context = { - device, - session: 'sess-1', - taskQueue: new CWMPTaskQueue(), - services: { deviceAction: {} as any }, - } - const result = await state.handle(context) - expect(result.isComplete).toBe(true) - }) -}) diff --git a/tests/unit/services/cwmp/main_state_machine_extra.spec.ts b/tests/unit/services/cwmp/main_state_machine_extra.spec.ts deleted file mode 100644 index 3ccdbbf..0000000 --- a/tests/unit/services/cwmp/main_state_machine_extra.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// Mock logger -vi.mock('@adonisjs/core/services/logger', () => ({ - default: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, -})) - -// Mock DeviceModel -vi.mock('../../../../app/models/device.js', () => ({ - default: { - createOrUpdate: vi.fn().mockResolvedValue({}), - findByDeviceId: vi.fn().mockResolvedValue(null), - updateOne: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock CommandModel -vi.mock('../../../../app/models/command.js', () => ({ - default: { - getCollection: vi.fn().mockReturnValue({ - find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), - countDocuments: vi.fn().mockResolvedValue(0), - }), - updateCommand: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock ConfigurationEngine to control evaluation results -vi.mock('../../../../app/services/cwmp/configuration_evaluation_state.js', () => ({ - ConfigurationEvaluationState: class { - name = 'configuration-evaluation' - async handle() { return { isComplete: true, response: '' } } - }, -})) - -// Mock reclassify -vi.mock('../../../../app/services/parameter_writability.js', () => ({ - reclassifyDeviceFromCatalog: vi.fn(), -})) - -import { MainStateMachine, OperationalState, PartialOperationalState } from '../../../../app/services/cwmp/main_state_machine.js' -import { Device } from '../../../../app/domain/device/device.js' -import { DeviceId } from '../../../../app/domain/device/device_id.js' -import { DeviceProperty } from '../../../../app/domain/device/device_parameter.js' -import { CWMPTaskQueue } from '../../../../app/services/cwmp/task_queue.js' -import { ParameterType } from '../../../../app/domain/shared/types.js' -import DeviceModel from '../../../../app/models/device.js' - -function makeDevice(gpnStatus: string = 'unsupported'): Device { - const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) - device.setDataModel('tr-181') - device.setGpnStatus(gpnStatus as any) - // Simulate unclassified Inform params - device.setTmpParamValues([ - { Name: 'Device.ManagementServer.ConnectionRequestURL', Value: { _: 'http://cpe/' } }, - ]) - return device -} - -describe('MainStateMachine — additional coverage (DiscoveringParameters, GPN flows)', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('sends GPN for tr-098 device when gpnStatus=unknown', async () => { - const device = new Device(new DeviceId('Huawei', '202BC1', 'BM632w', 'SN001')) - device.setDataModel('tr-098') - device.setGpnStatus('unknown') - device.setTmpParamValues([{ Name: 'InternetGatewayDevice.ManagementServer.URL', Value: { _: 'http://acs/' } }]) - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'sess', taskQueue) - - const response = await sm.start() - expect(response).toContain('GetParameterNames') - expect(response).toContain('InternetGatewayDevice.ManagementServer.') - }) - - it('skips GPN for unknown dataModel and goes to critical-reads', async () => { - const device = new Device(new DeviceId('Unknown', '000000', 'Generic', 'SN002')) - device.setDataModel('unknown') - device.setGpnStatus('unknown') - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'sess', taskQueue) - - const response = await sm.start() - // Should go to critical-reads (GPV) since unknown data model skips GPN - expect(response).toContain('GetParameterValues') - }) - - it('DiscoveringParametersState handles GPN success and transitions to CriticalReads', async () => { - const device = makeDevice('unknown') // gpnStatus=unknown → will attempt GPN - device.setGpnStatus('unknown') - device.setDataModel('tr-181') - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'sess', taskQueue) - - // Start: sends GPN for Device.ManagementServer. (single test path) - await sm.start() - - // GPN response → transitions to critical-reads and sends GPV - const response = await sm.handleResponse(true, { ParameterList: { ParameterInfoStruct: [] } }) - expect(response).toContain('GetParameterValues') - expect(sm.getCurrentStateName()).toBe('critical-reads') - }) - - it('handles MAX_STATE_TRANSITIONS exceeded gracefully', async () => { - // Create a state machine with a device that will loop - const device = makeDevice('unsupported') - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'sess', taskQueue) - - // The machine won't actually loop 15 times in normal flow since it completes - // Just verify the machine starts and runs without crashing - const response = await sm.start() - expect(response).toBeDefined() - }) -}) - -describe('OperationalState', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('persists device with operational status', async () => { - const device = makeDevice() - const taskQueue = new CWMPTaskQueue() - const context = { - device, - session: 'test-session', - taskQueue, - services: { deviceAction: {} as any }, - } - - const state = new OperationalState() - const result = await state.handle(context as any) - - expect(result.isComplete).toBe(true) - expect(device.operationalStatus).toBe('operational') - expect(vi.mocked(DeviceModel.createOrUpdate)).toHaveBeenCalledWith( - expect.objectContaining({ operationalStatus: 'operational' }) - ) - }) -}) - -describe('PartialOperationalState', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('persists device with partial_operational status and failure reason', async () => { - const device = makeDevice() - const taskQueue = new CWMPTaskQueue() - const context = { - device, - session: 'test-session', - taskQueue, - services: { deviceAction: {} as any }, - } - - const state = new PartialOperationalState(device, { - reason: 'test_failure', - blockingState: 'some-state', - }) - const result = await state.handle(context as any) - - expect(result.isComplete).toBe(true) - expect(device.operationalStatus).toBe('partial_operational') - expect(device.lastFailureReason).toBe('test_failure') - expect(vi.mocked(DeviceModel.createOrUpdate)).toHaveBeenCalledWith( - expect.objectContaining({ - operationalStatus: 'partial_operational', - lastFailureReason: 'test_failure', - }) - ) - }) -}) diff --git a/tests/unit/services/cwmp/stale_session.spec.ts b/tests/unit/services/cwmp/stale_session.spec.ts deleted file mode 100644 index fcdba28..0000000 --- a/tests/unit/services/cwmp/stale_session.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// Mock logger -vi.mock('@adonisjs/core/services/logger', () => ({ - default: { - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})) - -// Mock DeviceModel -vi.mock('../../../../app/models/device.js', () => ({ - default: { - createOrUpdate: vi.fn().mockResolvedValue({}), - findByDeviceId: vi.fn().mockResolvedValue(null), - updateOne: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock CommandModel -vi.mock('../../../../app/models/command.js', () => ({ - default: { - getCollection: vi.fn().mockReturnValue({ - find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), - countDocuments: vi.fn().mockResolvedValue(0), - }), - updateCommand: vi.fn().mockResolvedValue({}), - }, -})) - -import { StateMachineManager, MachineStatus } from '../../../../app/services/cwmp/state_machine_manager.js' -import { Device } from '../../../../app/domain/device/device.js' -import { DeviceId } from '../../../../app/domain/device/device_id.js' - -function makeDevice(): Device { - const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) - device.setDataModel('tr-181') - device.setGpnStatus('unsupported') - return device -} - -describe('StateMachineManager — stale session replacement', () => { - let manager: StateMachineManager - - beforeEach(() => { - vi.clearAllMocks() - // Reset singleton - ;(StateMachineManager as any).instance = null - manager = StateMachineManager.getInstance() - }) - - it('replaces stale MainStateMachine when session differs', async () => { - const device = makeDevice() - - // Start first machine on session-A - const machine1 = await manager.startStateMachine(device, 'session-A', { events: ['0 BOOTSTRAP'] }) - expect(machine1.status).toBe(MachineStatus.RUNNING) - expect(machine1.session).toBe('session-A') - - // Start second machine on session-B (different session, same device) - const machine2 = await manager.startStateMachine(device, 'session-B', { events: ['2 PERIODIC'] }) - - // Should be a NEW machine (not the old one rebinding) - expect(machine2.session).toBe('session-B') - // Old machine should be completed - expect(machine1.status).toBe(MachineStatus.COMPLETED) - }) - - it('does NOT replace machine when session is same', async () => { - const device = makeDevice() - - // Start machine on session-A - const machine1 = await manager.startStateMachine(device, 'session-A', { events: ['0 BOOTSTRAP'] }) - - // Start again on SAME session - const machine2 = await manager.startStateMachine(device, 'session-A', { events: ['2 PERIODIC'] }) - - // Should return the same machine (rebinding) - expect(machine2.id).toBe(machine1.id) - expect(machine1.status).toBe(MachineStatus.RUNNING) - }) - - it('creates fresh machine after replacing stale one', async () => { - const device = makeDevice() - - // Start first machine - await manager.startStateMachine(device, 'session-old', { events: ['0 BOOTSTRAP'] }) - - // Start new machine with different session - const newMachine = await manager.startStateMachine(device, 'session-new', { events: ['2 PERIODIC'] }) - - // Fresh machine should be running - expect(newMachine.status).toBe(MachineStatus.RUNNING) - expect(newMachine.session).toBe('session-new') - // Should be a MainStateMachine (PERIODIC event triggers MainStateMachine) - expect(newMachine.currentState).toBeDefined() - }) -}) diff --git a/tests/unit/services/cwmp/state_machine_manager.spec.ts b/tests/unit/services/cwmp/state_machine_manager.spec.ts new file mode 100644 index 0000000..a98148a --- /dev/null +++ b/tests/unit/services/cwmp/state_machine_manager.spec.ts @@ -0,0 +1,446 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@adonisjs/core/services/logger', () => ({ + default: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})) + +vi.mock('../../../../app/models/device.js', () => ({ + default: { createOrUpdate: vi.fn().mockResolvedValue({}), findByDeviceId: vi.fn().mockResolvedValue(null), updateOne: vi.fn().mockResolvedValue({}) }, +})) + +vi.mock('../../../../app/models/command.js', () => ({ + default: { + getCollection: vi.fn().mockReturnValue({ find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), countDocuments: vi.fn().mockResolvedValue(0) }), + updateCommand: vi.fn().mockResolvedValue({}), + }, +})) + +import { StateMachineManager, MachineStatus } from '../../../../app/services/cwmp/state_machine_manager.js' +import { Device } from '../../../../app/domain/device/device.js' +import { DeviceId } from '../../../../app/domain/device/device_id.js' +import { CWMPTaskQueue, ResultStatus } from '../../../../app/services/cwmp/task_queue.js' +import CommandModel from '../../../../app/models/command.js' + +function makeDevice(): Device { + const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) + device.setDataModel('tr-181') + device.setGpnStatus('unsupported') + return device +} + +describe('StateMachineManager — coverage', () => { + let manager: StateMachineManager + + beforeEach(() => { + vi.clearAllMocks() + ;(StateMachineManager as any).instance = null + manager = StateMachineManager.getInstance() + }) + + // --- Event routing --- + + it('routes BOOTSTRAP event to MainStateMachine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-1', { events: ['0 BOOTSTRAP'] }) + expect(machine.status).toBe(MachineStatus.RUNNING) + // MainStateMachine has getCurrentStateName + expect(machine.stateMachine.getCurrentStateName()).toBeDefined() + }) + + it('routes CONNECTION_REQUEST to ImmediateCommandStateMachine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-2', { events: ['6 CONNECTION REQUEST'] }) + expect(machine.status).toBe(MachineStatus.RUNNING) + expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands') + }) + + it('routes PERIODIC to MainStateMachine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-3', { events: ['2 PERIODIC'] }) + expect(machine.status).toBe(MachineStatus.RUNNING) + expect(machine.stateMachine.getCurrentStateName()).toBe('idle') + }) + + it('forces MainStateMachine when NI commands exist', async () => { + // Override the mock to return count > 0 + const collection = CommandModel.getCollection() + vi.mocked(collection.countDocuments).mockResolvedValueOnce(3) + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-4', { events: ['6 CONNECTION REQUEST'] }) + // Should be MainStateMachine (idle) not ImmediateCommandStateMachine + expect(machine.stateMachine.getCurrentStateName()).toBe('idle') + }) + + it('preempts ImmediateCommandStateMachine on BOOT event with same session', async () => { + const device = makeDevice() + // First: create ImmediateCommandStateMachine + const machine1 = await manager.startStateMachine(device, 'sess-5', { events: ['6 CONNECTION REQUEST'] }) + expect(machine1.stateMachine.getCurrentStateName()).toBe('executing-commands') + + // Second: BOOT event arrives on same session (same TCP connection, new Inform) + const machine2 = await manager.startStateMachine(device, 'sess-5', { events: ['1 BOOT'] }) + // Should have preempted to MainStateMachine (same machine instance, new state machine inside) + // Since gpnStatus=unsupported, it starts and goes directly to critical-reads + const stateName = machine2.stateMachine.getCurrentStateName() + expect(stateName === 'idle' || stateName === 'critical-reads').toBe(true) + }) + + // --- continue() --- + + it('continue() calls handleResponse on state machine', async () => { + const device = makeDevice() + await manager.startStateMachine(device, 'sess-6', { events: ['0 BOOTSTRAP'] }) + // continue sends empty POST handling + const response = await manager.continue('sess-6') + // Should have produced a GPV (critical reads since gpn=unsupported) + expect(response).toContain('GetParameterValues') + }) + + it('continue() treats empty body as task failure when lastTaskId is set', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-7', { events: ['0 BOOTSTRAP'] }) + // Manually set lastTaskId to simulate in-flight task + machine.lastTaskId = 'fake-task-id' + // Add a fake task to the queue so failTask doesn't error + manager.getTaskQueue().addCWMPTask(device.deviceId, 'get_parameter_values', []) + + const response = await manager.continue('sess-7') + // Should have treated as failure and advanced the state machine + expect(machine.lastTaskId).toBeUndefined() + }) + + it('continue() returns empty for unknown session', async () => { + const response = await manager.continue('unknown-session') + expect(response).toBe('') + }) + + // --- handleCWMPResponse() --- + + it('handleCWMPResponse() correlates response with lastTaskId and executes callback', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-8', { events: ['0 BOOTSTRAP'] }) + + // Start state machine (sends GPV) + await manager.continue('sess-8') + + // Manually set lastTaskId + const taskId = 'test-task-123' + machine.lastTaskId = taskId + const callbackFn = vi.fn().mockResolvedValue('') + manager.getTaskQueue()['taskCallbacks'].set(taskId, callbackFn) + manager.getTaskQueue()['tasks'].set(taskId, { id: taskId, deviceId: device.deviceId, type: 'get_parameter_values', payload: [], status: 'SENT' as any, createdAt: new Date() }) + + // Send success response + await manager.handleCWMPResponse('sess-8', true, { ParameterList: { ParameterValueStruct: [] } }) + + // Callback should have been invoked + expect(callbackFn).toHaveBeenCalled() + expect(machine.lastTaskId).toBeUndefined() + }) + + it('handleCWMPResponse() returns empty for unknown session', async () => { + const response = await manager.handleCWMPResponse('no-such-session', true, {}) + expect(response).toBe('') + }) + + // --- Session helpers --- + + it('hasSession returns false for unknown session', () => { + expect(manager.hasSession('nonexistent')).toBe(false) + }) + + it('hasSession returns true for registered session', async () => { + const device = makeDevice() + await manager.startStateMachine(device, 'sess-9', { events: ['0 BOOTSTRAP'] }) + expect(manager.hasSession('sess-9')).toBe(true) + }) + + it('ensureSessionForRemote rebinds session correctly', async () => { + const device = makeDevice() + await manager.startStateMachine(device, 'sess-10', { events: ['0 BOOTSTRAP'] }) + manager.bindRemoteToDevice('remote-1', device.deviceId) + + const result = manager.ensureSessionForRemote('remote-1', 'sess-10-new') + expect(result).toBe(true) + expect(manager.hasSession('sess-10-new')).toBe(true) + expect(manager.hasSession('sess-10')).toBe(false) + }) + + it('ensureSessionForRemote returns false for unknown remote', () => { + expect(manager.ensureSessionForRemote('unknown-remote', 'sess')).toBe(false) + }) + + // --- Cleanup --- + + it('cleanupCompletedMachines removes old completed machines', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-11', { events: ['0 BOOTSTRAP'] }) + // Force complete and set old completion date + ;(machine as any).status = MachineStatus.COMPLETED + ;(machine as any).completedAt = new Date(Date.now() - 2 * 60 * 60 * 1000) // 2h ago + + // Verify machine exists in internal map before cleanup + const machinesMap = (manager as any).machines as Map + expect(machinesMap.has(machine.id)).toBe(true) + + manager.cleanupCompletedMachines(1) + + // Machine should be removed from internal map + expect(machinesMap.has(machine.id)).toBe(false) + }) +}) + + +describe('StateMachineManager — additional coverage', () => { + let manager: StateMachineManager + + beforeEach(() => { + vi.clearAllMocks() + ;(StateMachineManager as any).instance = null + manager = StateMachineManager.getInstance() + }) + + // --- Event routing for less common events --- + + it('routes VALUE_CHANGE to MainStateMachine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-vc', { events: ['4 VALUE CHANGE'] }) + expect(machine.status).toBe(MachineStatus.RUNNING) + expect(machine.stateMachine.getCurrentStateName()).toBeDefined() + }) + + it('routes DIAGNOSTICS_COMPLETE to ImmediateCommandStateMachine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-dc', { events: ['8 DIAGNOSTICS COMPLETE'] }) + expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands') + }) + + it('routes TRANSFER_COMPLETE to ImmediateCommandStateMachine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-tc', { events: ['7 TRANSFER COMPLETE'] }) + expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands') + }) + + it('routes SCHEDULED to MainStateMachine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-sc', { events: ['3 SCHEDULED'] }) + expect(machine.stateMachine.getCurrentStateName()).toBeDefined() + }) + + it('routes unknown event to MainStateMachine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-unk', { events: ['99 UNKNOWN'] }) + expect(machine.stateMachine.getCurrentStateName()).toBeDefined() + }) + + // --- getHashIdForRemote --- + + it('getHashIdForRemote returns hash for bound remote', async () => { + const device = makeDevice() + await manager.startStateMachine(device, 'sess-hr', { events: ['0 BOOTSTRAP'] }) + manager.bindRemoteToDevice('10.0.0.1:5000', device.deviceId) + const hash = manager.getHashIdForRemote('10.0.0.1:5000') + expect(hash).toBeDefined() + expect(typeof hash).toBe('string') + expect(hash!.length).toBeGreaterThan(0) + }) + + it('getHashIdForRemote returns undefined for unknown remote', () => { + expect(manager.getHashIdForRemote('unknown:9999')).toBeUndefined() + }) + + // --- handleCWMPResponse with fault --- + + it('handleCWMPResponse calls failTask on fault response when lastTaskId set', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-fault', { events: ['0 BOOTSTRAP'] }) + await manager.continue('sess-fault') + + // Set lastTaskId + const taskId = 'task-fail-test' + machine.lastTaskId = taskId + const taskQueue = manager.getTaskQueue() + ;(taskQueue as any).tasks.set(taskId, { id: taskId, deviceId: device.deviceId, type: 'test', payload: {}, status: 'SENT', createdAt: new Date() }) + ;(taskQueue as any).taskCallbacks.set(taskId, vi.fn().mockResolvedValue('')) + + await manager.handleCWMPResponse('sess-fault', false, { FaultCode: 9001 }) + + // lastTaskId should be cleared + expect(machine.lastTaskId).toBeUndefined() + }) + + // --- continue() serving task from queue --- + + it('continue() serves pending task from queue when state machine returns empty', async () => { + // Start the immediate command SM (completes with no commands) + await manager.continue('sess-q') + + // After completion, continue should return '' + const response = await manager.continue('sess-q') + expect(response).toBe('') + }) + + // --- CONNECTION REQUEST replaces existing immediate --- + + it('new CONNECTION_REQUEST completes old immediate machine and creates fresh', async () => { + const device = makeDevice() + const machine1 = await manager.startStateMachine(device, 'sess-cr1', { events: ['6 CONNECTION REQUEST'] }) + expect(machine1.status).toBe(MachineStatus.RUNNING) + + // New CONNECTION REQUEST on different session + const machine2 = await manager.startStateMachine(device, 'sess-cr2', { events: ['6 CONNECTION REQUEST'] }) + expect(machine1.status).toBe(MachineStatus.COMPLETED) + expect(machine2.session).toBe('sess-cr2') + }) + + // --- stopCleanupTimer / startCleanupTimer --- + + it('startCleanupTimer and stopCleanupTimer work without error', () => { + manager.startCleanupTimer(100000) + manager.startCleanupTimer(100000) // second call should be no-op + manager.stopCleanupTimer() + manager.stopCleanupTimer() // second stop should be no-op + }) + + // --- shutdown --- + + it('shutdown stops timer and cleans up', async () => { + manager.startCleanupTimer(100000) + await manager.shutdown() + // Should not throw + }) + + // --- enqueueImmediate --- + + it('enqueueImmediate adds task to queue', async () => { + const device = makeDevice() + await manager.enqueueImmediate(device, 'get_parameter_values', ['Device.Info.SW']) + const task = manager.getTaskQueue().getTaskForDevice(device.deviceId) + expect(task).toBeDefined() + expect(task!.task).toBe('get_parameter_values') + }) +}) + +describe('StateMachineManager — error and edge case branches', () => { + let manager: StateMachineManager + + beforeEach(() => { + vi.clearAllMocks() + ;(StateMachineManager as any).instance = null + manager = StateMachineManager.getInstance() + }) + + it('handleCWMPResponse returns empty for non-RUNNING machine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-nr', { events: ['0 BOOTSTRAP'] }) + ;(machine as any).status = MachineStatus.COMPLETED + + const result = await manager.handleCWMPResponse('sess-nr', true, {}) + expect(result).toBe('') + }) + + it('continue returns empty for non-RUNNING machine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-cnr', { events: ['0 BOOTSTRAP'] }) + ;(machine as any).status = MachineStatus.FAILED + + const result = await manager.continue('sess-cnr') + expect(result).toBe('') + }) + + it('handleCWMPResponse catches state machine errors and fails machine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-err2', { events: ['0 BOOTSTRAP'] }) + vi.spyOn(machine.stateMachine, 'handleResponse').mockRejectedValueOnce(new Error('boom')) + + const result = await manager.handleCWMPResponse('sess-err2', true, {}) + expect(result).toBe('') + expect(machine.status).toBe(MachineStatus.FAILED) + }) + + it('continue catches errors and fails machine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-cerr2', { events: ['0 BOOTSTRAP'] }) + vi.spyOn(machine.stateMachine, 'handleResponse').mockRejectedValueOnce(new Error('crash')) + + const result = await manager.continue('sess-cerr2') + expect(result).toBe('') + expect(machine.status).toBe(MachineStatus.FAILED) + }) + + it('ensureSessionForRemote returns false for non-RUNNING machine', async () => { + const device = makeDevice() + const machine = await manager.startStateMachine(device, 'sess-enr2', { events: ['0 BOOTSTRAP'] }) + manager.bindRemoteToDevice('remote-xx', device.deviceId) + ;(machine as any).status = MachineStatus.COMPLETED + + const result = manager.ensureSessionForRemote('remote-xx', 'new-sess') + expect(result).toBe(false) + }) + + it('getHashIdForRemote returns undefined for invalid JSON', () => { + ;(manager as any).remoteToDevice.set('bad-remote', 'not-valid-json{') + expect(manager.getHashIdForRemote('bad-remote')).toBeUndefined() + }) +}) + + + +describe('StateMachineManager — stale session replacement', () => { + let manager: StateMachineManager + + beforeEach(() => { + vi.clearAllMocks() + // Reset singleton + ;(StateMachineManager as any).instance = null + manager = StateMachineManager.getInstance() + }) + + it('replaces stale MainStateMachine when session differs', async () => { + const device = makeDevice() + + // Start first machine on session-A + const machine1 = await manager.startStateMachine(device, 'session-A', { events: ['0 BOOTSTRAP'] }) + expect(machine1.status).toBe(MachineStatus.RUNNING) + expect(machine1.session).toBe('session-A') + + // Start second machine on session-B (different session, same device) + const machine2 = await manager.startStateMachine(device, 'session-B', { events: ['2 PERIODIC'] }) + + // Should be a NEW machine (not the old one rebinding) + expect(machine2.session).toBe('session-B') + // Old machine should be completed + expect(machine1.status).toBe(MachineStatus.COMPLETED) + }) + + it('does NOT replace machine when session is same', async () => { + const device = makeDevice() + + // Start machine on session-A + const machine1 = await manager.startStateMachine(device, 'session-A', { events: ['0 BOOTSTRAP'] }) + + // Start again on SAME session + const machine2 = await manager.startStateMachine(device, 'session-A', { events: ['2 PERIODIC'] }) + + // Should return the same machine (rebinding) + expect(machine2.id).toBe(machine1.id) + expect(machine1.status).toBe(MachineStatus.RUNNING) + }) + + it('creates fresh machine after replacing stale one', async () => { + const device = makeDevice() + + // Start first machine + await manager.startStateMachine(device, 'session-old', { events: ['0 BOOTSTRAP'] }) + + // Start new machine with different session + const newMachine = await manager.startStateMachine(device, 'session-new', { events: ['2 PERIODIC'] }) + + // Fresh machine should be running + expect(newMachine.status).toBe(MachineStatus.RUNNING) + expect(newMachine.session).toBe('session-new') + // Should be a MainStateMachine (PERIODIC event triggers MainStateMachine) + expect(newMachine.currentState).toBeDefined() + }) +}) + diff --git a/tests/unit/services/cwmp/state_machine_manager_coverage.spec.ts b/tests/unit/services/cwmp/state_machine_manager_coverage.spec.ts deleted file mode 100644 index f2467fb..0000000 --- a/tests/unit/services/cwmp/state_machine_manager_coverage.spec.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// Mock logger -vi.mock('@adonisjs/core/services/logger', () => ({ - default: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, -})) - -// Mock DeviceModel -vi.mock('../../../../app/models/device.js', () => ({ - default: { - createOrUpdate: vi.fn().mockResolvedValue({}), - findByDeviceId: vi.fn().mockResolvedValue(null), - updateOne: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock CommandModel -vi.mock('../../../../app/models/command.js', () => ({ - default: { - getCollection: vi.fn().mockReturnValue({ - find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), - countDocuments: vi.fn().mockResolvedValue(0), - }), - updateCommand: vi.fn().mockResolvedValue({}), - }, -})) - -import { StateMachineManager, MachineStatus } from '../../../../app/services/cwmp/state_machine_manager.js' -import { Device } from '../../../../app/domain/device/device.js' -import { DeviceId } from '../../../../app/domain/device/device_id.js' -import { ResultStatus } from '../../../../app/services/cwmp/task_queue.js' -import CommandModel from '../../../../app/models/command.js' - -function makeDevice(gpnStatus: string = 'unsupported'): Device { - const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) - device.setDataModel('tr-181') - device.setGpnStatus(gpnStatus as any) - return device -} - -describe('StateMachineManager — coverage', () => { - let manager: StateMachineManager - - beforeEach(() => { - vi.clearAllMocks() - ;(StateMachineManager as any).instance = null - manager = StateMachineManager.getInstance() - }) - - // --- Event routing --- - - it('routes BOOTSTRAP event to MainStateMachine', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-1', { events: ['0 BOOTSTRAP'] }) - expect(machine.status).toBe(MachineStatus.RUNNING) - // MainStateMachine has getCurrentStateName - expect(machine.stateMachine.getCurrentStateName()).toBeDefined() - }) - - it('routes CONNECTION_REQUEST to ImmediateCommandStateMachine', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-2', { events: ['6 CONNECTION REQUEST'] }) - expect(machine.status).toBe(MachineStatus.RUNNING) - expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands') - }) - - it('routes PERIODIC to MainStateMachine', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-3', { events: ['2 PERIODIC'] }) - expect(machine.status).toBe(MachineStatus.RUNNING) - expect(machine.stateMachine.getCurrentStateName()).toBe('idle') - }) - - it('forces MainStateMachine when NI commands exist', async () => { - // Override the mock to return count > 0 - const collection = CommandModel.getCollection() - vi.mocked(collection.countDocuments).mockResolvedValueOnce(3) - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-4', { events: ['6 CONNECTION REQUEST'] }) - // Should be MainStateMachine (idle) not ImmediateCommandStateMachine - expect(machine.stateMachine.getCurrentStateName()).toBe('idle') - }) - - it('preempts ImmediateCommandStateMachine on BOOT event with same session', async () => { - const device = makeDevice() - // First: create ImmediateCommandStateMachine - const machine1 = await manager.startStateMachine(device, 'sess-5', { events: ['6 CONNECTION REQUEST'] }) - expect(machine1.stateMachine.getCurrentStateName()).toBe('executing-commands') - - // Second: BOOT event arrives on same session (same TCP connection, new Inform) - const machine2 = await manager.startStateMachine(device, 'sess-5', { events: ['1 BOOT'] }) - // Should have preempted to MainStateMachine (same machine instance, new state machine inside) - // Since gpnStatus=unsupported, it starts and goes directly to critical-reads - const stateName = machine2.stateMachine.getCurrentStateName() - expect(stateName === 'idle' || stateName === 'critical-reads').toBe(true) - }) - - // --- continue() --- - - it('continue() calls handleResponse on state machine', async () => { - const device = makeDevice() - await manager.startStateMachine(device, 'sess-6', { events: ['0 BOOTSTRAP'] }) - // continue sends empty POST handling - const response = await manager.continue('sess-6') - // Should have produced a GPV (critical reads since gpn=unsupported) - expect(response).toContain('GetParameterValues') - }) - - it('continue() treats empty body as task failure when lastTaskId is set', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-7', { events: ['0 BOOTSTRAP'] }) - // Manually set lastTaskId to simulate in-flight task - machine.lastTaskId = 'fake-task-id' - // Add a fake task to the queue so failTask doesn't error - manager.getTaskQueue().addCWMPTask(device.deviceId, 'get_parameter_values', []) - - const response = await manager.continue('sess-7') - // Should have treated as failure and advanced the state machine - expect(machine.lastTaskId).toBeUndefined() - }) - - it('continue() returns empty for unknown session', async () => { - const response = await manager.continue('unknown-session') - expect(response).toBe('') - }) - - // --- handleCWMPResponse() --- - - it('handleCWMPResponse() correlates response with lastTaskId and executes callback', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-8', { events: ['0 BOOTSTRAP'] }) - - // Start state machine (sends GPV) - await manager.continue('sess-8') - - // Manually set lastTaskId - const taskId = 'test-task-123' - machine.lastTaskId = taskId - const callbackFn = vi.fn().mockResolvedValue('') - manager.getTaskQueue()['taskCallbacks'].set(taskId, callbackFn) - manager.getTaskQueue()['tasks'].set(taskId, { id: taskId, deviceId: device.deviceId, type: 'get_parameter_values', payload: [], status: 'SENT' as any, createdAt: new Date() }) - - // Send success response - await manager.handleCWMPResponse('sess-8', true, { ParameterList: { ParameterValueStruct: [] } }) - - // Callback should have been invoked - expect(callbackFn).toHaveBeenCalled() - expect(machine.lastTaskId).toBeUndefined() - }) - - it('handleCWMPResponse() returns empty for unknown session', async () => { - const response = await manager.handleCWMPResponse('no-such-session', true, {}) - expect(response).toBe('') - }) - - // --- Session helpers --- - - it('hasSession returns false for unknown session', () => { - expect(manager.hasSession('nonexistent')).toBe(false) - }) - - it('hasSession returns true for registered session', async () => { - const device = makeDevice() - await manager.startStateMachine(device, 'sess-9', { events: ['0 BOOTSTRAP'] }) - expect(manager.hasSession('sess-9')).toBe(true) - }) - - it('ensureSessionForRemote rebinds session correctly', async () => { - const device = makeDevice() - await manager.startStateMachine(device, 'sess-10', { events: ['0 BOOTSTRAP'] }) - manager.bindRemoteToDevice('remote-1', device.deviceId) - - const result = manager.ensureSessionForRemote('remote-1', 'sess-10-new') - expect(result).toBe(true) - expect(manager.hasSession('sess-10-new')).toBe(true) - expect(manager.hasSession('sess-10')).toBe(false) - }) - - it('ensureSessionForRemote returns false for unknown remote', () => { - expect(manager.ensureSessionForRemote('unknown-remote', 'sess')).toBe(false) - }) - - // --- Cleanup --- - - it('cleanupCompletedMachines removes old completed machines', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-11', { events: ['0 BOOTSTRAP'] }) - // Force complete and set old completion date - ;(machine as any).status = MachineStatus.COMPLETED - ;(machine as any).completedAt = new Date(Date.now() - 2 * 60 * 60 * 1000) // 2h ago - - // Verify machine exists in internal map before cleanup - const machinesMap = (manager as any).machines as Map - expect(machinesMap.has(machine.id)).toBe(true) - - manager.cleanupCompletedMachines(1) - - // Machine should be removed from internal map - expect(machinesMap.has(machine.id)).toBe(false) - }) -}) diff --git a/tests/unit/services/cwmp/state_machine_manager_extra.spec.ts b/tests/unit/services/cwmp/state_machine_manager_extra.spec.ts deleted file mode 100644 index 85688ac..0000000 --- a/tests/unit/services/cwmp/state_machine_manager_extra.spec.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// Mock logger -vi.mock('@adonisjs/core/services/logger', () => ({ - default: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, -})) - -// Mock DeviceModel -vi.mock('../../../../app/models/device.js', () => ({ - default: { - createOrUpdate: vi.fn().mockResolvedValue({}), - findByDeviceId: vi.fn().mockResolvedValue(null), - updateOne: vi.fn().mockResolvedValue({}), - }, -})) - -// Mock CommandModel -vi.mock('../../../../app/models/command.js', () => ({ - default: { - getCollection: vi.fn().mockReturnValue({ - find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), - countDocuments: vi.fn().mockResolvedValue(0), - }), - updateCommand: vi.fn().mockResolvedValue({}), - }, -})) - -import { StateMachineManager, MachineStatus } from '../../../../app/services/cwmp/state_machine_manager.js' -import { Device } from '../../../../app/domain/device/device.js' -import { DeviceId } from '../../../../app/domain/device/device_id.js' - -function makeDevice(): Device { - const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) - device.setDataModel('tr-181') - device.setGpnStatus('unsupported') - return device -} - -describe('StateMachineManager — additional coverage', () => { - let manager: StateMachineManager - - beforeEach(() => { - vi.clearAllMocks() - ;(StateMachineManager as any).instance = null - manager = StateMachineManager.getInstance() - }) - - // --- Event routing for less common events --- - - it('routes VALUE_CHANGE to MainStateMachine', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-vc', { events: ['4 VALUE CHANGE'] }) - expect(machine.status).toBe(MachineStatus.RUNNING) - expect(machine.stateMachine.getCurrentStateName()).toBeDefined() - }) - - it('routes DIAGNOSTICS_COMPLETE to ImmediateCommandStateMachine', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-dc', { events: ['8 DIAGNOSTICS COMPLETE'] }) - expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands') - }) - - it('routes TRANSFER_COMPLETE to ImmediateCommandStateMachine', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-tc', { events: ['7 TRANSFER COMPLETE'] }) - expect(machine.stateMachine.getCurrentStateName()).toBe('executing-commands') - }) - - it('routes SCHEDULED to MainStateMachine', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-sc', { events: ['3 SCHEDULED'] }) - expect(machine.stateMachine.getCurrentStateName()).toBeDefined() - }) - - it('routes unknown event to MainStateMachine', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-unk', { events: ['99 UNKNOWN'] }) - expect(machine.stateMachine.getCurrentStateName()).toBeDefined() - }) - - // --- getHashIdForRemote --- - - it('getHashIdForRemote returns hash for bound remote', async () => { - const device = makeDevice() - await manager.startStateMachine(device, 'sess-hr', { events: ['0 BOOTSTRAP'] }) - manager.bindRemoteToDevice('10.0.0.1:5000', device.deviceId) - const hash = manager.getHashIdForRemote('10.0.0.1:5000') - expect(hash).toBeDefined() - expect(typeof hash).toBe('string') - expect(hash!.length).toBeGreaterThan(0) - }) - - it('getHashIdForRemote returns undefined for unknown remote', () => { - expect(manager.getHashIdForRemote('unknown:9999')).toBeUndefined() - }) - - // --- handleCWMPResponse with fault --- - - it('handleCWMPResponse calls failTask on fault response when lastTaskId set', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-fault', { events: ['0 BOOTSTRAP'] }) - await manager.continue('sess-fault') - - // Set lastTaskId - const taskId = 'task-fail-test' - machine.lastTaskId = taskId - const taskQueue = manager.getTaskQueue() - ;(taskQueue as any).tasks.set(taskId, { id: taskId, deviceId: device.deviceId, type: 'test', payload: {}, status: 'SENT', createdAt: new Date() }) - ;(taskQueue as any).taskCallbacks.set(taskId, vi.fn().mockResolvedValue('')) - - await manager.handleCWMPResponse('sess-fault', false, { FaultCode: 9001 }) - - // lastTaskId should be cleared - expect(machine.lastTaskId).toBeUndefined() - }) - - // --- continue() serving task from queue --- - - it('continue() serves pending task from queue when state machine returns empty', async () => { - // Start the immediate command SM (completes with no commands) - await manager.continue('sess-q') - - // After completion, continue should return '' - const response = await manager.continue('sess-q') - expect(response).toBe('') - }) - - // --- CONNECTION REQUEST replaces existing immediate --- - - it('new CONNECTION_REQUEST completes old immediate machine and creates fresh', async () => { - const device = makeDevice() - const machine1 = await manager.startStateMachine(device, 'sess-cr1', { events: ['6 CONNECTION REQUEST'] }) - expect(machine1.status).toBe(MachineStatus.RUNNING) - - // New CONNECTION REQUEST on different session - const machine2 = await manager.startStateMachine(device, 'sess-cr2', { events: ['6 CONNECTION REQUEST'] }) - expect(machine1.status).toBe(MachineStatus.COMPLETED) - expect(machine2.session).toBe('sess-cr2') - }) - - // --- stopCleanupTimer / startCleanupTimer --- - - it('startCleanupTimer and stopCleanupTimer work without error', () => { - manager.startCleanupTimer(100000) - manager.startCleanupTimer(100000) // second call should be no-op - manager.stopCleanupTimer() - manager.stopCleanupTimer() // second stop should be no-op - }) - - // --- shutdown --- - - it('shutdown stops timer and cleans up', async () => { - manager.startCleanupTimer(100000) - await manager.shutdown() - // Should not throw - }) - - // --- enqueueImmediate --- - - it('enqueueImmediate adds task to queue', async () => { - const device = makeDevice() - await manager.enqueueImmediate(device, 'get_parameter_values', ['Device.Info.SW']) - const task = manager.getTaskQueue().getTaskForDevice(device.deviceId) - expect(task).toBeDefined() - expect(task!.task).toBe('get_parameter_values') - }) -}) - -describe('StateMachineManager — error and edge case branches', () => { - let manager: StateMachineManager - - beforeEach(() => { - vi.clearAllMocks() - ;(StateMachineManager as any).instance = null - manager = StateMachineManager.getInstance() - }) - - it('handleCWMPResponse returns empty for non-RUNNING machine', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-nr', { events: ['0 BOOTSTRAP'] }) - ;(machine as any).status = MachineStatus.COMPLETED - - const result = await manager.handleCWMPResponse('sess-nr', true, {}) - expect(result).toBe('') - }) - - it('continue returns empty for non-RUNNING machine', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-cnr', { events: ['0 BOOTSTRAP'] }) - ;(machine as any).status = MachineStatus.FAILED - - const result = await manager.continue('sess-cnr') - expect(result).toBe('') - }) - - it('handleCWMPResponse catches state machine errors and fails machine', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-err2', { events: ['0 BOOTSTRAP'] }) - vi.spyOn(machine.stateMachine, 'handleResponse').mockRejectedValueOnce(new Error('boom')) - - const result = await manager.handleCWMPResponse('sess-err2', true, {}) - expect(result).toBe('') - expect(machine.status).toBe(MachineStatus.FAILED) - }) - - it('continue catches errors and fails machine', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-cerr2', { events: ['0 BOOTSTRAP'] }) - vi.spyOn(machine.stateMachine, 'handleResponse').mockRejectedValueOnce(new Error('crash')) - - const result = await manager.continue('sess-cerr2') - expect(result).toBe('') - expect(machine.status).toBe(MachineStatus.FAILED) - }) - - it('ensureSessionForRemote returns false for non-RUNNING machine', async () => { - const device = makeDevice() - const machine = await manager.startStateMachine(device, 'sess-enr2', { events: ['0 BOOTSTRAP'] }) - manager.bindRemoteToDevice('remote-xx', device.deviceId) - ;(machine as any).status = MachineStatus.COMPLETED - - const result = manager.ensureSessionForRemote('remote-xx', 'new-sess') - expect(result).toBe(false) - }) - - it('getHashIdForRemote returns undefined for invalid JSON', () => { - ;(manager as any).remoteToDevice.set('bad-remote', 'not-valid-json{') - expect(manager.getHashIdForRemote('bad-remote')).toBeUndefined() - }) -}) - diff --git a/tests/unit/services/main_state_machine.spec.ts b/tests/unit/services/main_state_machine.spec.ts deleted file mode 100644 index f7bde85..0000000 --- a/tests/unit/services/main_state_machine.spec.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -// Mock AdonisJS logger before imports -vi.mock('@adonisjs/core/services/logger', () => ({ - default: { - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})) - -// Mock reclassifyDeviceFromCatalog -vi.mock('../../../app/services/parameter_writability.js', () => ({ - reclassifyDeviceFromCatalog: vi.fn(), -})) - -// Mock DeviceModel to avoid MongoDB dependency -vi.mock('../../../app/models/device.js', () => ({ - default: { - createOrUpdate: vi.fn().mockResolvedValue({}), - findByDeviceId: vi.fn().mockResolvedValue(null), - }, -})) - -// Mock CommandModel -vi.mock('../../../app/models/command.js', () => ({ - default: { - getCollection: vi.fn().mockReturnValue({ - find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnValue({ toArray: vi.fn().mockResolvedValue([]) }) }), - countDocuments: vi.fn().mockResolvedValue(0), - }), - }, -})) - -import { MainStateMachine } from '../../../app/services/cwmp/main_state_machine.js' -import { Device } from '../../../app/domain/device/device.js' -import { DeviceId } from '../../../app/domain/device/device_id.js' -import { DeviceProperty } from '../../../app/domain/device/device_parameter.js' -import { CWMPTaskQueue } from '../../../app/services/cwmp/task_queue.js' -import { ParameterType } from '../../../app/domain/shared/types.js' -import { reclassifyDeviceFromCatalog } from '../../../app/services/parameter_writability.js' - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeTr181Device(): Device { - const device = new Device(new DeviceId('Sagemcom', 'F8B7FF', 'Livebox 4', 'LB4-TEST-001')) - device.setDataModel('tr-181') - device.setTmpParamValues([{ Name: 'Device.ManagementServer.ConnectionRequestURL', Value: { _: 'http://cpe/' } }]) - return device -} - -function makeTr098Device(): Device { - const device = new Device(new DeviceId('Huawei Technologies Co., Ltd.', '202BC1', 'BM632w', '000000')) - device.setDataModel('tr-098') - device.setTmpParamValues([{ Name: 'InternetGatewayDevice.ManagementServer.ConnectionRequestURL', Value: { _: 'http://cpe/' } }]) - return device -} - -function makeUnknownDevice(): Device { - const device = new Device(new DeviceId('Unknown', '000000', 'Generic', 'SN001')) - device.setDataModel('unknown') - return device -} - -// --------------------------------------------------------------------------- -// MainStateMachine — Initial state -// --------------------------------------------------------------------------- - -describe('MainStateMachine — initial state', () => { - it('starts in idle state', () => { - const device = makeTr181Device() - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'session-1', taskQueue) - - expect(sm.getCurrentStateName()).toBe('idle') - expect(sm.isCompleted()).toBe(false) - }) -}) - -// --------------------------------------------------------------------------- -// MainStateMachine — TR-181 device flow (GPN on Device.ManagementServer.) -// --------------------------------------------------------------------------- - -describe('MainStateMachine — TR-181 device', () => { - let device: Device - let taskQueue: CWMPTaskQueue - let sm: MainStateMachine - - beforeEach(() => { - device = makeTr181Device() - taskQueue = new CWMPTaskQueue() - sm = new MainStateMachine(device, 'session-1', taskQueue) - }) - - it('transitions to discovering-parameters and sends GPN for Device.ManagementServer.', async () => { - const response = await sm.start() - - expect(sm.getCurrentStateName()).toBe('discovering-parameters') - // Response should be a SOAP envelope with GetParameterNames - expect(response).toContain('GetParameterNames') - expect(response).toContain('Device.ManagementServer.') - }) - - it('advances past discovering-parameters after receiving a response', async () => { - await sm.start() // sends GPN, state = discovering-parameters - - // Simulate any response (success or failure) — state machine advances - await sm.handleResponse(true, {}) - - // Should have moved past discovering-parameters (now in critical-reads) - expect(sm.getCurrentStateName()).not.toBe('discovering-parameters') - expect(sm.getCurrentStateName()).not.toBe('idle') - }) - - it('GPN callback sets gpnStatus to unsupported on fault code 9000', async () => { - await sm.start() - - // Get the task from the queue and execute its callback manually - const tasks: any[] = Array.from((taskQueue as any).tasks.values()) - const gpnTask = tasks.find((t: any) => t.type === 'get_parameter_names') - expect(gpnTask).toBeDefined() - - // Execute the callback with error - await taskQueue.executeTaskCallback(gpnTask!.id, 'error' as any, { - FaultCode: 9000, - FaultString: 'Method not supported', - }) - - expect(device.gpnStatus).toBe('unsupported') - }) - - it('GPN callback sets gpnStatus to supported on success', async () => { - await sm.start() - - const tasks: any[] = Array.from((taskQueue as any).tasks.values()) - const gpnTask = tasks.find((t: any) => t.type === 'get_parameter_names') - expect(gpnTask).toBeDefined() - - // Execute the callback with success - await taskQueue.executeTaskCallback(gpnTask!.id, 'success' as any, { - ParameterList: { - ParameterInfoStruct: [ - { Name: 'Device.ManagementServer.URL', Writable: true }, - { Name: 'Device.ManagementServer.ParameterKey', Writable: false }, - ], - }, - }) - - expect(device.gpnStatus).toBe('supported') - }) - - it('GPN callback organizes parameters correctly on success', async () => { - device.setProperty(new DeviceProperty('Device.ManagementServer.URL', 'http://acs', ParameterType.STRING)) - device.setProperty(new DeviceProperty('Device.ManagementServer.ParameterKey', '', ParameterType.STRING)) - - await sm.start() - - const tasks: any[] = Array.from((taskQueue as any).tasks.values()) - const gpnTask = tasks.find((t: any) => t.type === 'get_parameter_names') - - await taskQueue.executeTaskCallback(gpnTask!.id, 'success' as any, { - ParameterList: { - ParameterInfoStruct: [ - { Name: 'Device.ManagementServer.URL', Writable: true }, - { Name: 'Device.ManagementServer.ParameterKey', Writable: false }, - ], - }, - }) - - // URL was property + writable in GPN → should be parameter now - expect(device.getParameter('Device.ManagementServer.URL')).not.toBeNull() - // ParameterKey was property + NOT writable → stays property - expect(device.getProperty('Device.ManagementServer.ParameterKey')).not.toBeNull() - }) -}) - -// --------------------------------------------------------------------------- -// MainStateMachine — TR-098 device flow (GPN on InternetGatewayDevice.ManagementServer.) -// --------------------------------------------------------------------------- - -describe('MainStateMachine — TR-098 device', () => { - let device: Device - let taskQueue: CWMPTaskQueue - let sm: MainStateMachine - - beforeEach(() => { - device = makeTr098Device() - taskQueue = new CWMPTaskQueue() - sm = new MainStateMachine(device, 'session-1', taskQueue) - }) - - it('sends GPN for InternetGatewayDevice.ManagementServer.', async () => { - const response = await sm.start() - - expect(sm.getCurrentStateName()).toBe('discovering-parameters') - expect(response).toContain('GetParameterNames') - expect(response).toContain('InternetGatewayDevice.ManagementServer.') - }) -}) - -// --------------------------------------------------------------------------- -// MainStateMachine — Unknown data model -// --------------------------------------------------------------------------- - -describe('MainStateMachine — unknown data model', () => { - it('skips GPN and goes directly to critical-reads', async () => { - const device = makeUnknownDevice() - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'session-1', taskQueue) - - await sm.start() - - // Should skip discovering-parameters and go to critical-reads - expect(sm.getCurrentStateName()).not.toBe('discovering-parameters') - expect(sm.getCurrentStateName()).not.toBe('idle') - expect(sm.getCurrentStateName()).toBe('critical-reads') - }) -}) - -// --------------------------------------------------------------------------- -// MainStateMachine — CompletedState reclassification -// --------------------------------------------------------------------------- - -describe('MainStateMachine — IdleState reclassification via catalog', () => { - it('reclassifies unclassified params via catalog when GPN unsupported', async () => { - const device = makeTr181Device() - device.setGpnStatus('unsupported') - // tmpParamValues has an unclassified param (not in _parameters or _properties) - device.setTmpParamValues([ - { Name: 'Device.ManagementServer.PeriodicInformInterval', Value: { _: '60' } }, - ]) - // This param is NOT in device._parameters or device._properties → unclassified - - const taskQueue = new CWMPTaskQueue() - const sm = new MainStateMachine(device, 'session-1', taskQueue) - - // Start → IdleState detects unclassified param, GPN unsupported → calls catalog - await sm.start() - - // reclassifyDeviceFromCatalog should have been called - expect(vi.mocked(reclassifyDeviceFromCatalog)).toHaveBeenCalled() - }) -}) - -// --------------------------------------------------------------------------- -// DeviceActionExecutor — reclassification after SPV (Livebox fallback scenario) -// --------------------------------------------------------------------------- - -describe('DeviceActionExecutor — reclassification after updateFromValues', () => { - it('SPV callback updates device values but does not reclassify (consolidated to terminal state)', async () => { - const device = makeTr181Device() - device.setGpnStatus('unsupported') - // Device starts with only read-only properties (like after first Inform) - device.setProperty(new DeviceProperty('Device.DeviceInfo.Manufacturer', 'Sagemcom', ParameterType.STRING)) - - const taskQueue = new CWMPTaskQueue() - - // Import DeviceActionExecutor - const { DeviceActionExecutor } = await import('../../../app/services/cwmp/device_action_executor.js') - const executor = new DeviceActionExecutor(taskQueue) - - // Simulate SPV for ProvisioningCode (like ConfigurationEvaluationState does) - const spvXml = await executor.applySetParameterValues( - device, - [{ parameterName: 'Device.DeviceInfo.ProvisioningCode', value: 'test-code' }], - async () => '' - ) - - expect(spvXml).toContain('SetParameterValues') - - // Get the task and execute callback with success (simulating CPE response) - const tasks: any[] = Array.from((taskQueue as any).tasks.values()) - const spvTask = tasks.find((t: any) => t.type === 'set_parameter_values') - expect(spvTask).toBeDefined() - - await taskQueue.executeTaskCallback(spvTask!.id, 'success' as any, { Status: 0 }) - - // ProvisioningCode is stored as a property (reclassification happens later in OperationalState) - expect(device.getProperty('Device.DeviceInfo.ProvisioningCode')).not.toBeNull() - expect(device.getProperty('Device.DeviceInfo.ProvisioningCode')!.value).toBe('test-code') - }) - - it('does NOT reclassify when GPN is supported (live data is authoritative)', async () => { - const device = makeTr181Device() - device.setGpnStatus('supported') - device.setProperty(new DeviceProperty('Device.DeviceInfo.Manufacturer', 'Sagemcom', ParameterType.STRING)) - - const taskQueue = new CWMPTaskQueue() - const { DeviceActionExecutor } = await import('../../../app/services/cwmp/device_action_executor.js') - const executor = new DeviceActionExecutor(taskQueue) - - // SPV for a writable param — but GPN is supported so no reclassification - await executor.applySetParameterValues( - device, - [{ parameterName: 'Device.ManagementServer.PeriodicInformInterval', value: '120' }], - async () => '' - ) - - const tasks: any[] = Array.from((taskQueue as any).tasks.values()) - const spvTask = tasks.find((t: any) => t.type === 'set_parameter_values') - await taskQueue.executeTaskCallback(spvTask!.id, 'success' as any, { Status: 0 }) - - // PeriodicInformInterval was added via updateFromValues as a property - // But since GPN is supported, reclassification is NOT called - // It stays as property (the GPN response is the authority) - expect(device.getProperty('Device.ManagementServer.PeriodicInformInterval')).not.toBeNull() - expect(device.getParameter('Device.ManagementServer.PeriodicInformInterval')).toBeNull() - }) -})