feat: reference providers system with Wassup integration
- Pluggable multi-provider architecture (ReferenceEngine) - Wassup provider implementation (HTTP GET + X_WASSUP_USER_IP) - Auto-resolve references on Inform (BOOT/BOOTSTRAP events) - References displayed in device Identity card - REST API: GET/POST references per device - Config via config/references.json - Fix event extraction for single EventStruct and numeric EventCodes - Mock Wassup provisioned by home-device-simulator on device creation
This commit is contained in:
1 parent
4eb3f0795a
commit
e857b4fbe7
16 files changed
+1154
-7
No files matched your search
@@ -0,0 +1,228 @@
|
||||
# Feature : Références Client — Système de Providers Pluggable
|
||||
|
||||
## Contexte
|
||||
|
||||
Dans adam-acs, on veut un système de **références externes** (informations client) associées aux devices. Inspiré de Wassup (karma-com) mais conçu comme un **système de plugins** permettant de brancher N fournisseurs de données.
|
||||
|
||||
## Principe : N Providers
|
||||
|
||||
Chaque device peut être enrichi par **plusieurs** providers indépendants. Chaque provider est un service externe interrogé via HTTP qui retourne des paires clé-valeur.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ adam-acs │
|
||||
│ │
|
||||
│ DeviceRecord │
|
||||
│ └─ references: [ │
|
||||
Inform ──────► │ { provider: "wassup", ... } │
|
||||
│ { provider: "crm", ... } │
|
||||
│ { provider: "noc", ... } │
|
||||
│ ] │
|
||||
│ │
|
||||
│ ReferenceEngine │
|
||||
│ ├─ WassupProvider │───► Wassup (HTTP)
|
||||
│ ├─ CRMProvider │───► CRM API
|
||||
│ └─ NOCProvider │───► NOC API
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Interface Provider
|
||||
|
||||
Chaque provider implémente une interface commune :
|
||||
|
||||
```typescript
|
||||
interface ReferenceProvider {
|
||||
/** Identifiant unique du provider (ex: "wassup", "crm") */
|
||||
name: string
|
||||
|
||||
/** Résout les références pour un device. Retourne null si non applicable. */
|
||||
resolve(device: DeviceRecord, context: InformContext): Promise<ResolvedReference | null>
|
||||
|
||||
/** Indique si ce provider est activé */
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface ResolvedReference {
|
||||
provider: string
|
||||
resolvedAt: Date
|
||||
resolvedBy: string // ex: "ip:10.0.0.1"
|
||||
properties: Record<string, string>
|
||||
status: 'ok' | 'unknown' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface InformContext {
|
||||
events: string[]
|
||||
remoteIp: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
```
|
||||
|
||||
### ReferenceEngine (orchestrateur)
|
||||
|
||||
```typescript
|
||||
class ReferenceEngine {
|
||||
private providers: ReferenceProvider[] = []
|
||||
|
||||
register(provider: ReferenceProvider): void
|
||||
|
||||
/** Résout tous les providers pour un device (en parallèle) */
|
||||
async resolveAll(device: DeviceRecord, context: InformContext): Promise<ResolvedReference[]>
|
||||
|
||||
/** Résout un seul provider par nom */
|
||||
async resolveOne(providerName: string, device: DeviceRecord, context: InformContext): Promise<ResolvedReference | null>
|
||||
}
|
||||
```
|
||||
|
||||
### Modèle de données (MongoDB)
|
||||
|
||||
```typescript
|
||||
// Ajouté à DeviceRecord
|
||||
interface DeviceRecord {
|
||||
// ... champs existants ...
|
||||
references?: DeviceReference[]
|
||||
}
|
||||
|
||||
interface DeviceReference {
|
||||
provider: string
|
||||
resolvedAt: Date
|
||||
resolvedBy: string
|
||||
status: 'ok' | 'unknown' | 'error'
|
||||
properties: Record<string, string>
|
||||
error?: string
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration : `config/references.json`
|
||||
|
||||
Fichier de config qui déclare les providers actifs et leur paramétrage :
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"name": "wassup",
|
||||
"type": "wassup",
|
||||
"enabled": true,
|
||||
"url": "http://wassup-mock:9999",
|
||||
"timeout": 3000,
|
||||
"trigger": ["BOOT", "BOOTSTRAP"],
|
||||
"resolveBy": "ip",
|
||||
"requestedProperties": ["sai", "spr", "clo", "puid", "utz"]
|
||||
},
|
||||
{
|
||||
"name": "crm",
|
||||
"type": "http-json",
|
||||
"enabled": false,
|
||||
"url": "http://crm-api:8080/api/customers",
|
||||
"timeout": 5000,
|
||||
"trigger": ["BOOT"],
|
||||
"resolveBy": "serialNumber",
|
||||
"mapping": {
|
||||
"requestParam": "serial",
|
||||
"responseMapping": {
|
||||
"customerId": "id",
|
||||
"customerName": "name",
|
||||
"offer": "subscription.offer"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Types de providers supportés
|
||||
|
||||
| Type | Description | Protocole |
|
||||
|------|-------------|-----------|
|
||||
| `wassup` | Protocole Wassup (HTTP GET + headers + text/plain response) | Conforme au format Wassup Orange |
|
||||
| `http-json` | API REST standard (GET avec query params, réponse JSON) | Générique |
|
||||
| `http-properties` | HTTP GET avec réponse key=value (Java properties) | Générique |
|
||||
| `static` | Valeurs statiques déclarées dans la config (pas d'appel externe) | Local |
|
||||
|
||||
### Champ `resolveBy` — Comment identifier le device auprès du provider
|
||||
|
||||
| Valeur | Source dans le device | Exemple |
|
||||
|--------|----------------------|---------|
|
||||
| `ip` | Header `x-forwarded-for` de l'Inform ou IP source | `10.0.0.1` |
|
||||
| `serialNumber` | `device.deviceId.serialNumber` | `LB4-TEST-001` |
|
||||
| `oui` | `device.deviceId.oui` | `F8B7FF` |
|
||||
| `mac` | Paramètre MACAddress du device | `F8:B7:FF:00:11:22` |
|
||||
| `parameter:<path>` | Valeur d'un paramètre du device | `Device.ManagementServer.Username` |
|
||||
|
||||
### Champ `trigger` — Quand déclencher la résolution
|
||||
|
||||
| Valeur | Événement |
|
||||
|--------|-----------|
|
||||
| `BOOT` | Event "1 BOOT" |
|
||||
| `BOOTSTRAP` | Event "0 BOOTSTRAP" |
|
||||
| `PERIODIC` | Event "2 PERIODIC" |
|
||||
| `SCHEDULED` | Event "3 SCHEDULED" |
|
||||
| `CONNECTION_REQUEST` | Event "6 CONNECTION REQUEST" |
|
||||
| `MANUAL` | Uniquement via API (pas automatique) |
|
||||
|
||||
## Intégration dans le flux Inform
|
||||
|
||||
```typescript
|
||||
// Dans cwmp_controller.ts → handleInform(), après persist device :
|
||||
const informContext: InformContext = { events, remoteIp: request.ip(), headers: request.headers() }
|
||||
const references = await referenceEngine.resolveAll(device, informContext)
|
||||
if (references.length > 0) {
|
||||
await DeviceModel.updateReferences(hashId, references)
|
||||
}
|
||||
```
|
||||
|
||||
## API REST
|
||||
|
||||
| Méthode | Route | Description |
|
||||
|---------|-------|-------------|
|
||||
| `GET` | `/api/v1/devices/:id/references` | Toutes les références du device |
|
||||
| `GET` | `/api/v1/devices/:id/references/:provider` | Références d'un provider spécifique |
|
||||
| `POST` | `/api/v1/devices/:id/references/resolve` | Déclencher la résolution (tous les providers) |
|
||||
| `POST` | `/api/v1/devices/:id/references/resolve/:provider` | Résoudre un seul provider |
|
||||
| `PUT` | `/api/v1/devices/:id/references/:provider` | Mettre à jour manuellement |
|
||||
| `DELETE` | `/api/v1/devices/:id/references/:provider` | Supprimer les références d'un provider |
|
||||
|
||||
## UI (DeviceDetail.vue)
|
||||
|
||||
Nouvelle section collapsible "Références" dans la page device :
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Références (2 providers) ▾ │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 🟢 wassup — résolu le 02/06/2026 via ip:10.0.0.1│
|
||||
│ SAI: 088913491 │
|
||||
│ SPR: EN │
|
||||
│ CLO: pppLB.user │
|
||||
│ [Rafraîchir] │
|
||||
│ │
|
||||
│ ⚪ crm — non résolu (désactivé) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Fichiers à créer/modifier dans adam-acs
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `config/references.json` | Créer — configuration des providers |
|
||||
| `app/domain/device/device_record.ts` | Modifier — ajouter `references?: DeviceReference[]` |
|
||||
| `app/services/references/reference_engine.ts` | Créer — orchestrateur |
|
||||
| `app/services/references/providers/wassup_provider.ts` | Créer — provider Wassup |
|
||||
| `app/services/references/providers/http_json_provider.ts` | Créer — provider HTTP JSON générique |
|
||||
| `app/services/references/types.ts` | Créer — interfaces et types |
|
||||
| `app/controllers/references_controller.ts` | Créer — API REST |
|
||||
| `app/controllers/cwmp_controller.ts` | Modifier — appel ReferenceEngine dans handleInform |
|
||||
| `inertia/pages/DeviceDetail.vue` | Modifier — section Références |
|
||||
| `start/routes.ts` | Modifier — ajouter routes references |
|
||||
|
||||
## Mock Wassup (gogowassupgo)
|
||||
|
||||
Déjà implémenté et testé. Le mock tourne dans Docker (`wassup-mock:9999`) et est provisionné automatiquement par le simulateur lors de la création de devices.
|
||||
|
||||
- **Protocole** : identique à Wassup (HTTP GET + `X_WASSUP_USER_IP`)
|
||||
- **Provisioning** : API `/admin/entries` (POST/GET/DELETE)
|
||||
- **Stockage** : `data/entries.json`
|
||||
- **Modes d'erreur** : `--error http-500`, `--error-status KO`, `--delay 2s`
|
||||
@@ -0,0 +1,65 @@
|
||||
# Tâches : Références Client — Providers Pluggable
|
||||
|
||||
## Tâche 1 : Types et interfaces
|
||||
- [ ] Créer `app/services/references/types.ts`
|
||||
- Interface `ReferenceProvider`
|
||||
- Interface `ResolvedReference`
|
||||
- Interface `InformContext`
|
||||
- Interface `ProviderConfig` (issue de `config/references.json`)
|
||||
- Type `ReferenceProviderType = 'wassup' | 'http-json' | 'http-properties' | 'static'`
|
||||
|
||||
## Tâche 2 : Modèle de données
|
||||
- [ ] Modifier `app/domain/device/device_record.ts` — ajouter `references?: DeviceReference[]`
|
||||
- [ ] Créer le type `DeviceReference` (provider, resolvedAt, resolvedBy, status, properties, error?)
|
||||
- [ ] Modifier le modèle MongoDB `app/models/device.ts` pour persister/lire `references`
|
||||
|
||||
## Tâche 3 : Configuration
|
||||
- [ ] Créer `config/references.json` avec un provider Wassup exemple (pointant vers `http://wassup-mock:9999`)
|
||||
- [ ] Créer un loader de config `app/services/references/config_loader.ts` qui lit et valide le fichier
|
||||
|
||||
## Tâche 4 : ReferenceEngine (orchestrateur)
|
||||
- [ ] Créer `app/services/references/reference_engine.ts`
|
||||
- Méthode `register(provider)` pour enregistrer un provider
|
||||
- Méthode `resolveAll(device, context)` — résout tous les providers actifs en parallèle
|
||||
- Méthode `resolveOne(providerName, device, context)` — résout un seul
|
||||
- Chargement automatique des providers depuis la config au démarrage
|
||||
- Extraction du `resolveBy` (ip, serialNumber, mac, parameter:path)
|
||||
|
||||
## Tâche 5 : Provider Wassup
|
||||
- [ ] Créer `app/services/references/providers/wassup_provider.ts`
|
||||
- Implémente `ReferenceProvider`
|
||||
- Construit la requête HTTP GET avec header `X_WASSUP_USER_IP`
|
||||
- Parse la réponse text/plain (key=value)
|
||||
- Gère les statuts UNKNOWN_USER et erreurs
|
||||
- Timeout configurable
|
||||
|
||||
## Tâche 6 : Intégration dans le flux Inform
|
||||
- [ ] Modifier `app/controllers/cwmp_controller.ts` → `handleInform()`
|
||||
- Après persist device, appeler `referenceEngine.resolveAll(device, context)` si les events matchent un trigger
|
||||
- Stocker les résultats dans le device via `DeviceModel.updateReferences(hashId, references)`
|
||||
|
||||
## Tâche 7 : API REST
|
||||
- [ ] Créer `app/controllers/references_controller.ts`
|
||||
- `GET /api/v1/devices/:id/references`
|
||||
- `GET /api/v1/devices/:id/references/:provider`
|
||||
- `POST /api/v1/devices/:id/references/resolve`
|
||||
- `POST /api/v1/devices/:id/references/resolve/:provider`
|
||||
- [ ] Ajouter les routes dans `start/routes.ts`
|
||||
|
||||
## Tâche 8 : UI — Section Références dans DeviceDetail
|
||||
- [ ] Modifier `inertia/pages/DeviceDetail.vue`
|
||||
- Ajouter une section collapsible "Références"
|
||||
- Afficher chaque provider avec son statut (🟢 ok / 🔴 error / ⚪ non résolu)
|
||||
- Afficher les propriétés clé-valeur
|
||||
- Bouton "Rafraîchir" par provider
|
||||
|
||||
## Tâche 9 : Tests
|
||||
- [ ] Test unitaire `reference_engine.spec.ts` — resolve all, resolve one, provider non activé
|
||||
- [ ] Test unitaire `wassup_provider.spec.ts` — succès, unknown user, timeout, erreur HTTP
|
||||
- [ ] Test intégration Docker — créer device dans simulateur → vérifier références provisionnées
|
||||
|
||||
## Ordre d'exécution recommandé
|
||||
|
||||
```
|
||||
T1 (types) → T2 (modèle) → T3 (config) → T4 (engine) → T5 (wassup) → T6 (inform) → T7 (api) → T8 (ui) → T9 (tests)
|
||||
```
|
||||
@@ -59,7 +59,8 @@ export default class CwmpController {
|
||||
sessionId,
|
||||
envelope?.header?.id,
|
||||
remoteKey,
|
||||
manager
|
||||
manager,
|
||||
request.header('x-forwarded-for') || ''
|
||||
)
|
||||
ctx.cwmpDeviceId = hashId
|
||||
return this.sendSoap(response, xml)
|
||||
@@ -102,7 +103,8 @@ export default class CwmpController {
|
||||
session: string,
|
||||
cwmpId: string | undefined,
|
||||
remoteKey: string,
|
||||
manager: StateMachineManager
|
||||
manager: StateMachineManager,
|
||||
xForwardedFor?: string
|
||||
): Promise<{ xml: string; hashId: string }> {
|
||||
logger.info(
|
||||
{ informKeys: Object.keys(informData || {}), deviceId: informData?.DeviceId },
|
||||
@@ -159,11 +161,14 @@ export default class CwmpController {
|
||||
logger.info({ hashId }, 'Inform received')
|
||||
|
||||
// Extract events
|
||||
const events: string[] = Array.isArray(informData?.Event?.EventStruct)
|
||||
? informData.Event.EventStruct.map((e: any) => e?.EventCode).filter(
|
||||
(c: any): c is string => !!c
|
||||
)
|
||||
: []
|
||||
const rawEventStruct = informData?.Event?.EventStruct
|
||||
const eventStructArray = Array.isArray(rawEventStruct)
|
||||
? rawEventStruct
|
||||
: rawEventStruct ? [rawEventStruct] : []
|
||||
const events: string[] = eventStructArray
|
||||
.map((e: any) => e?.EventCode)
|
||||
.filter((c: any) => c != null)
|
||||
.map((c: any) => String(c))
|
||||
|
||||
logger.info({ events }, 'CWMP Inform events extracted')
|
||||
|
||||
@@ -171,6 +176,27 @@ export default class CwmpController {
|
||||
await manager.startStateMachine(device, session, { events })
|
||||
manager.bindRemoteToDevice(remoteKey, device.deviceId)
|
||||
|
||||
// Resolve references from configured providers (async, non-blocking)
|
||||
try {
|
||||
logger.debug({ hashId, events, xForwardedFor }, '[References] Starting resolution')
|
||||
const { getReferenceEngine } = await import('../services/references/reference_engine.js')
|
||||
const engine = getReferenceEngine()
|
||||
// Use x-forwarded-for header (set by simulator) or fall back to TCP remote IP
|
||||
const cpeIp = (xForwardedFor || '').split(',')[0]?.trim() || remoteKey.split(':')[0]
|
||||
const informContext = { events, remoteIp: cpeIp, headers: {} }
|
||||
const references = await engine.resolveAll(
|
||||
{ identifier: hashId, deviceId, properties: device.getProperties().map((p) => p.toSnapshot()), parameters: device.getParameters().map((p) => p.toSnapshot()) },
|
||||
informContext
|
||||
)
|
||||
if (references.length > 0) {
|
||||
await DeviceModel.updateOne({ identifier: hashId }, { $set: { references, updatedAt: new Date() } }).catch(() => {})
|
||||
logger.debug({ hashId, providers: references.map((r) => r.provider) }, '[References] Resolved')
|
||||
}
|
||||
} catch (_e: any) {
|
||||
// Non-critical — don't block Inform processing
|
||||
logger.error({ error: _e?.message, stack: _e?.stack }, '[References] Error during resolution')
|
||||
}
|
||||
|
||||
// After Inform for onboarding events, enqueue critical reads using the detected data model
|
||||
const onlyConnReq = events.length > 0 && events.every((e) => e === '6 CONNECTION REQUEST')
|
||||
if (!onlyConnReq) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import DeviceModel from '#models/device'
|
||||
import { getReferenceEngine } from '../services/references/reference_engine.js'
|
||||
|
||||
export default class ReferencesController {
|
||||
/**
|
||||
* GET /api/v1/devices/:identifier/references
|
||||
*/
|
||||
async index({ params, response }: HttpContext) {
|
||||
const device = await DeviceModel.findByIdentifier(params.identifier)
|
||||
if (!device) {
|
||||
return response.status(404).json({ message: 'Device not found' })
|
||||
}
|
||||
return response.json({ references: device.references || [] })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/devices/:identifier/references/:provider
|
||||
*/
|
||||
async show({ params, response }: HttpContext) {
|
||||
const device = await DeviceModel.findByIdentifier(params.identifier)
|
||||
if (!device) {
|
||||
return response.status(404).json({ message: 'Device not found' })
|
||||
}
|
||||
const ref = (device.references || []).find((r: any) => r.provider === params.provider)
|
||||
if (!ref) {
|
||||
return response.status(404).json({ message: `No reference from provider '${params.provider}'` })
|
||||
}
|
||||
return response.json(ref)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/devices/:identifier/references/resolve
|
||||
* Trigger resolution for all applicable providers.
|
||||
*/
|
||||
async resolveAll({ params, response }: HttpContext) {
|
||||
const device = await DeviceModel.findByIdentifier(params.identifier)
|
||||
if (!device) {
|
||||
return response.status(404).json({ message: 'Device not found' })
|
||||
}
|
||||
|
||||
const engine = getReferenceEngine()
|
||||
const context = { events: ['MANUAL'], remoteIp: '', headers: {} }
|
||||
|
||||
// For manual resolution, use all providers regardless of trigger
|
||||
const results = await engine.resolveAll(device, { ...context, events: ['0 BOOTSTRAP'] })
|
||||
|
||||
if (results.length > 0) {
|
||||
// Merge with existing references (replace by provider name)
|
||||
const existing = (device.references || []).filter(
|
||||
(r: any) => !results.some((nr) => nr.provider === r.provider)
|
||||
)
|
||||
const merged = [...existing, ...results]
|
||||
await DeviceModel.updateOne({ identifier: params.identifier }, { $set: { references: merged, updatedAt: new Date() } })
|
||||
}
|
||||
|
||||
return response.json({ resolved: results.length, references: results })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/devices/:identifier/references/resolve/:provider
|
||||
* Trigger resolution for a single provider.
|
||||
*/
|
||||
async resolveOne({ params, response }: HttpContext) {
|
||||
const device = await DeviceModel.findByIdentifier(params.identifier)
|
||||
if (!device) {
|
||||
return response.status(404).json({ message: 'Device not found' })
|
||||
}
|
||||
|
||||
const engine = getReferenceEngine()
|
||||
const context = { events: ['0 BOOTSTRAP'], remoteIp: '', headers: {} }
|
||||
const result = await engine.resolveOne(params.provider, device, context)
|
||||
|
||||
if (!result) {
|
||||
return response.status(404).json({ message: `Provider '${params.provider}' not found or cannot resolve` })
|
||||
}
|
||||
|
||||
// Merge into device references
|
||||
const existing = (device.references || []).filter((r: any) => r.provider !== params.provider)
|
||||
await DeviceModel.updateOne(
|
||||
{ identifier: params.identifier },
|
||||
{ $set: { references: [...existing, result], updatedAt: new Date() } }
|
||||
)
|
||||
|
||||
return response.json(result)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { DeviceId } from './device_id.js'
|
||||
import { PropertySnapshot, ParameterSnapshot } from './device_parameter.js'
|
||||
import { DeviceComplianceInfo } from '../configuration/compliance.js'
|
||||
import { DataModel, GpnStatus } from './device.js'
|
||||
import type { DeviceReference } from '../../services/references/types.js'
|
||||
|
||||
export interface DeviceRecord {
|
||||
identifier?: string
|
||||
@@ -12,6 +13,7 @@ export interface DeviceRecord {
|
||||
dataModel?: DataModel
|
||||
compliance?: DeviceComplianceInfo
|
||||
provisioningCode?: string
|
||||
references?: DeviceReference[]
|
||||
updatedAt?: Date
|
||||
createdAt?: Date
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { ReferencesConfig } from './types.js'
|
||||
|
||||
const CONFIG_PATH = join(process.cwd(), 'config', 'references.json')
|
||||
|
||||
let cachedConfig: ReferencesConfig | null = null
|
||||
|
||||
export function loadReferencesConfig(): ReferencesConfig {
|
||||
if (cachedConfig) return cachedConfig
|
||||
|
||||
try {
|
||||
const raw = readFileSync(CONFIG_PATH, 'utf-8')
|
||||
cachedConfig = JSON.parse(raw) as ReferencesConfig
|
||||
return cachedConfig
|
||||
} catch (_error: any) {
|
||||
cachedConfig = { providers: [] }
|
||||
return cachedConfig
|
||||
}
|
||||
}
|
||||
|
||||
export function reloadReferencesConfig(): ReferencesConfig {
|
||||
cachedConfig = null
|
||||
return loadReferencesConfig()
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { DeviceRecord } from '../../../domain/device/device_record.js'
|
||||
import type {
|
||||
ReferenceProvider,
|
||||
ResolvedReference,
|
||||
InformContext,
|
||||
ProviderConfig,
|
||||
} from '../types.js'
|
||||
import { ReferenceEngine } from '../reference_engine.js'
|
||||
|
||||
|
||||
/**
|
||||
* Wassup Provider — resolves customer identity via the Wassup protocol.
|
||||
*
|
||||
* Protocol:
|
||||
* - HTTP GET to {url}/wassup?info=sai,spr,...
|
||||
* - Header X_WASSUP_USER_IP with the CPE IP address
|
||||
* - Response: text/plain with key=value lines (X_WASSUP_STATUS=OK, X_WASSUP_SAI=..., etc.)
|
||||
*/
|
||||
export class WassupProvider implements ReferenceProvider {
|
||||
name: string
|
||||
enabled: boolean
|
||||
|
||||
private url: string
|
||||
private timeout: number
|
||||
private service: string
|
||||
private requestedProperties: string[]
|
||||
private resolveBy: string
|
||||
|
||||
constructor(config: ProviderConfig) {
|
||||
this.name = config.name
|
||||
this.enabled = config.enabled
|
||||
this.url = config.url || ''
|
||||
this.timeout = config.timeout || 3000
|
||||
this.resolveBy = config.resolveBy || 'ip'
|
||||
this.service = config.options?.service || 'KRMSCO'
|
||||
this.requestedProperties = config.options?.requestedProperties || ['sai', 'spr', 'clo', 'puid', 'utz']
|
||||
}
|
||||
|
||||
async resolve(device: DeviceRecord, context: InformContext): Promise<ResolvedReference | null> {
|
||||
const resolveValue = ReferenceEngine.extractResolveValue(device, this.resolveBy, context)
|
||||
if (!resolveValue) {
|
||||
console.debug({ provider: this.name, resolveBy: this.resolveBy }, '[Wassup] Cannot resolve: no value')
|
||||
return null
|
||||
}
|
||||
|
||||
const infoParam = this.requestedProperties.join(',')
|
||||
const wassupUrl = `${this.url}/wassup?wassup=ident&prot=200&charset=utf8&serv=${this.service}&info=${infoParam}`
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), this.timeout)
|
||||
|
||||
const response = await fetch(wassupUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X_WASSUP_USER_IP': resolveValue,
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timer)
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
provider: this.name,
|
||||
resolvedAt: new Date(),
|
||||
resolvedBy: `${this.resolveBy}:${resolveValue}`,
|
||||
status: 'error',
|
||||
properties: {},
|
||||
error: `HTTP ${response.status}`,
|
||||
}
|
||||
}
|
||||
|
||||
const body = await response.text()
|
||||
const parsed = this.parseResponse(body)
|
||||
|
||||
const status = parsed['X_WASSUP_STATUS']
|
||||
if (status === 'UNKNOWN_USER') {
|
||||
return {
|
||||
provider: this.name,
|
||||
resolvedAt: new Date(),
|
||||
resolvedBy: `${this.resolveBy}:${resolveValue}`,
|
||||
status: 'unknown',
|
||||
properties: {},
|
||||
}
|
||||
}
|
||||
|
||||
if (status !== 'OK') {
|
||||
return {
|
||||
provider: this.name,
|
||||
resolvedAt: new Date(),
|
||||
resolvedBy: `${this.resolveBy}:${resolveValue}`,
|
||||
status: 'error',
|
||||
properties: {},
|
||||
error: parsed['X_WASSUP_ERROR'] || `Unexpected status: ${status}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Map X_WASSUP_* keys to short names
|
||||
const properties: Record<string, string> = {}
|
||||
const keyMapping: Record<string, string> = {
|
||||
'X_WASSUP_SAI': 'sai',
|
||||
'X_WASSUP_SPR': 'spr',
|
||||
'X_WASSUP_CLO': 'clo',
|
||||
'X_WASSUP_PUID': 'puid',
|
||||
'X_WASSUP_UTZ': 'utz',
|
||||
'X_WASSUP_IUR': 'iur',
|
||||
'X_WASSUP_IUS': 'ius',
|
||||
}
|
||||
|
||||
for (const [wassupKey, shortKey] of Object.entries(keyMapping)) {
|
||||
if (parsed[wassupKey] && parsed[wassupKey] !== '') {
|
||||
properties[shortKey] = parsed[wassupKey]
|
||||
}
|
||||
}
|
||||
|
||||
console.info({ provider: this.name, resolveBy: `${this.resolveBy}:${resolveValue}`, keys: Object.keys(properties) }, '[Wassup] Resolved')
|
||||
|
||||
return {
|
||||
provider: this.name,
|
||||
resolvedAt: new Date(),
|
||||
resolvedBy: `${this.resolveBy}:${resolveValue}`,
|
||||
status: 'ok',
|
||||
properties,
|
||||
}
|
||||
} catch (error: any) {
|
||||
const isTimeout = error.name === 'AbortError'
|
||||
console.error({ provider: this.name, error: error.message, isTimeout }, '[Wassup] Resolution failed')
|
||||
|
||||
return {
|
||||
provider: this.name,
|
||||
resolvedAt: new Date(),
|
||||
resolvedBy: `${this.resolveBy}:${resolveValue}`,
|
||||
status: 'error',
|
||||
properties: {},
|
||||
error: isTimeout ? 'Timeout' : error.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Wassup text/plain response into a key-value map.
|
||||
* Format: "X_WASSUP_STATUS=OK\nX_WASSUP_SAI=111111111\n..."
|
||||
*/
|
||||
private parseResponse(body: string): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
const lines = body.split('\n')
|
||||
for (const line of lines) {
|
||||
const idx = line.indexOf('=')
|
||||
if (idx > 0) {
|
||||
const key = line.slice(0, idx).trim()
|
||||
const value = line.slice(idx + 1).trim()
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { DeviceRecord } from '../../domain/device/device_record.js'
|
||||
import type {
|
||||
ReferenceProvider,
|
||||
ResolvedReference,
|
||||
InformContext,
|
||||
ProviderConfig,
|
||||
} from './types.js'
|
||||
import { TRIGGER_EVENT_MAP } from './types.js'
|
||||
import { loadReferencesConfig } from './config_loader.js'
|
||||
import { WassupProvider } from './providers/wassup_provider.js'
|
||||
|
||||
export class ReferenceEngine {
|
||||
private providers: ReferenceProvider[] = []
|
||||
private configs: ProviderConfig[] = []
|
||||
|
||||
constructor() {
|
||||
this.loadProviders()
|
||||
}
|
||||
|
||||
private loadProviders(): void {
|
||||
const config = loadReferencesConfig()
|
||||
this.configs = config.providers
|
||||
|
||||
for (const providerConfig of config.providers) {
|
||||
if (!providerConfig.enabled) continue
|
||||
|
||||
const provider = this.createProvider(providerConfig)
|
||||
if (provider) {
|
||||
this.providers.push(provider)
|
||||
console.info({ name: providerConfig.name, type: providerConfig.type }, '[References] Provider registered')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createProvider(config: ProviderConfig): ReferenceProvider | null {
|
||||
switch (config.type) {
|
||||
case 'wassup':
|
||||
return new WassupProvider(config)
|
||||
case 'http-json':
|
||||
// TODO: implement
|
||||
console.warn({ name: config.name }, '[References] http-json provider not yet implemented')
|
||||
return null
|
||||
case 'http-properties':
|
||||
console.warn({ name: config.name }, '[References] http-properties provider not yet implemented')
|
||||
return null
|
||||
case 'static':
|
||||
console.warn({ name: config.name }, '[References] static provider not yet implemented')
|
||||
return null
|
||||
default:
|
||||
console.error({ name: config.name, type: config.type }, '[References] Unknown provider type')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all providers for a device in parallel.
|
||||
* Only triggers providers whose trigger events match the inform events.
|
||||
*/
|
||||
async resolveAll(device: DeviceRecord, context: InformContext): Promise<ResolvedReference[]> {
|
||||
const applicable = this.getApplicableProviders(context.events)
|
||||
if (applicable.length === 0) return []
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
applicable.map((p) => p.resolve(device, context))
|
||||
)
|
||||
|
||||
const resolved: ResolvedReference[] = []
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
resolved.push(result.value)
|
||||
} else if (result.status === 'rejected') {
|
||||
console.error({ error: result.reason }, '[References] Provider resolution failed')
|
||||
}
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single provider by name.
|
||||
*/
|
||||
async resolveOne(providerName: string, device: DeviceRecord, context: InformContext): Promise<ResolvedReference | null> {
|
||||
const provider = this.providers.find((p) => p.name === providerName)
|
||||
if (!provider) {
|
||||
console.warn({ providerName }, '[References] Provider not found')
|
||||
return null
|
||||
}
|
||||
return provider.resolve(device, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns providers whose trigger events intersect with the inform events.
|
||||
*/
|
||||
private getApplicableProviders(informEvents: string[]): ReferenceProvider[] {
|
||||
return this.providers.filter((provider) => {
|
||||
const config = this.configs.find((c) => c.name === provider.name)
|
||||
if (!config) return false
|
||||
|
||||
// Check if any trigger event matches the inform events
|
||||
for (const trigger of config.trigger) {
|
||||
const mappedEvents = TRIGGER_EVENT_MAP[trigger] || [trigger]
|
||||
for (const mapped of mappedEvents) {
|
||||
if (informEvents.some((e) => String(e).toUpperCase().includes(mapped.toUpperCase()))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the resolve value from a device based on the resolveBy config.
|
||||
*/
|
||||
static extractResolveValue(device: DeviceRecord, resolveBy: string, context: InformContext): string | null {
|
||||
switch (resolveBy) {
|
||||
case 'ip':
|
||||
return context.remoteIp || null
|
||||
case 'serialNumber':
|
||||
return device.deviceId?.serialNumber || null
|
||||
case 'oui':
|
||||
return device.deviceId?.oui || null
|
||||
case 'mac': {
|
||||
const allParams = [...(device.properties || []), ...(device.parameters || [])]
|
||||
const macParam = allParams.find((p) => p.name?.includes('MACAddress'))
|
||||
return macParam?.value as string || null
|
||||
}
|
||||
default:
|
||||
if (resolveBy.startsWith('parameter:')) {
|
||||
const path = resolveBy.slice('parameter:'.length)
|
||||
const allParams = [...(device.properties || []), ...(device.parameters || [])]
|
||||
const param = allParams.find((p) => p.name === path)
|
||||
return param?.value as string || null
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
getProviderNames(): string[] {
|
||||
return this.providers.map((p) => p.name)
|
||||
}
|
||||
|
||||
getProviderConfigs(): ProviderConfig[] {
|
||||
return this.configs
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
let instance: ReferenceEngine | null = null
|
||||
|
||||
export function getReferenceEngine(): ReferenceEngine {
|
||||
if (!instance) {
|
||||
instance = new ReferenceEngine()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Reference Providers — Types and Interfaces
|
||||
*
|
||||
* A pluggable system for resolving external customer/device information
|
||||
* from N independent providers (Wassup, CRM, NOC, etc.)
|
||||
*/
|
||||
|
||||
import type { DeviceRecord } from '../../domain/device/device_record.js'
|
||||
|
||||
// ─── Provider Interface ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReferenceProvider {
|
||||
/** Unique identifier for this provider (e.g., "wassup", "crm") */
|
||||
name: string
|
||||
|
||||
/** Whether this provider is currently enabled */
|
||||
enabled: boolean
|
||||
|
||||
/**
|
||||
* Resolve references for a device.
|
||||
* Returns null if the provider cannot resolve (e.g., missing input data).
|
||||
*/
|
||||
resolve(device: DeviceRecord, context: InformContext): Promise<ResolvedReference | null>
|
||||
}
|
||||
|
||||
// ─── Resolution Result ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface ResolvedReference {
|
||||
/** Provider name that produced this result */
|
||||
provider: string
|
||||
|
||||
/** Timestamp of resolution */
|
||||
resolvedAt: Date
|
||||
|
||||
/** Criterion used to resolve (e.g., "ip:10.0.0.1", "serial:LB4-001") */
|
||||
resolvedBy: string
|
||||
|
||||
/** Resolution outcome */
|
||||
status: ReferenceStatus
|
||||
|
||||
/** Key-value properties returned by the provider */
|
||||
properties: Record<string, string>
|
||||
|
||||
/** Error message if status is 'error' */
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type ReferenceStatus = 'ok' | 'unknown' | 'error'
|
||||
|
||||
// ─── Stored Reference (persisted in DeviceRecord) ──────────────────────────────
|
||||
|
||||
export interface DeviceReference {
|
||||
provider: string
|
||||
resolvedAt: Date
|
||||
resolvedBy: string
|
||||
status: ReferenceStatus
|
||||
properties: Record<string, string>
|
||||
error?: string
|
||||
}
|
||||
|
||||
// ─── Inform Context (passed to providers during resolution) ────────────────────
|
||||
|
||||
export interface InformContext {
|
||||
/** CWMP events from the Inform (e.g., ["1 BOOT", "0 BOOTSTRAP"]) */
|
||||
events: string[]
|
||||
|
||||
/** Remote IP of the CPE (from TCP socket or x-forwarded-for) */
|
||||
remoteIp: string
|
||||
|
||||
/** HTTP headers from the Inform request */
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
// ─── Provider Configuration (from config/references.json) ──────────────────────
|
||||
|
||||
export type ReferenceProviderType = 'wassup' | 'http-json' | 'http-properties' | 'static'
|
||||
|
||||
export interface ProviderConfig {
|
||||
/** Unique name for this provider instance */
|
||||
name: string
|
||||
|
||||
/** Provider type (determines which implementation to use) */
|
||||
type: ReferenceProviderType
|
||||
|
||||
/** Whether this provider is active */
|
||||
enabled: boolean
|
||||
|
||||
/** Base URL of the external service */
|
||||
url?: string
|
||||
|
||||
/** HTTP timeout in milliseconds */
|
||||
timeout?: number
|
||||
|
||||
/** Events that trigger automatic resolution (e.g., ["BOOT", "BOOTSTRAP"]) */
|
||||
trigger: string[]
|
||||
|
||||
/**
|
||||
* How to identify the device for this provider.
|
||||
* - "ip": use remoteIp from InformContext
|
||||
* - "serialNumber": use device.deviceId.serialNumber
|
||||
* - "oui": use device.deviceId.oui
|
||||
* - "mac": look for a MACAddress parameter
|
||||
* - "parameter:<path>": use value of a specific device parameter
|
||||
*/
|
||||
resolveBy: string
|
||||
|
||||
/** Provider-specific options */
|
||||
options?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ReferencesConfig {
|
||||
providers: ProviderConfig[]
|
||||
}
|
||||
|
||||
// ─── Trigger Event Mapping ─────────────────────────────────────────────────────
|
||||
|
||||
export const TRIGGER_EVENT_MAP: Record<string, string[]> = {
|
||||
BOOT: ['1 BOOT', '1'],
|
||||
BOOTSTRAP: ['0 BOOTSTRAP', '0'],
|
||||
PERIODIC: ['2 PERIODIC', '2'],
|
||||
SCHEDULED: ['3 SCHEDULED', '3'],
|
||||
CONNECTION_REQUEST: ['6 CONNECTION REQUEST', '6'],
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"name": "wassup",
|
||||
"type": "wassup",
|
||||
"enabled": true,
|
||||
"url": "http://wassup-mock:9999",
|
||||
"timeout": 3000,
|
||||
"trigger": ["BOOT", "BOOTSTRAP"],
|
||||
"resolveBy": "ip",
|
||||
"options": {
|
||||
"service": "KRMSCO",
|
||||
"requestedProperties": ["sai", "spr", "clo", "puid", "utz"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"10.0.0.1": {
|
||||
"sai": "999",
|
||||
"spr": "FR"
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ services:
|
||||
PROXY: "false"
|
||||
BSE_PROXY: "false"
|
||||
AUTO_DELETE_ENABLED: "false"
|
||||
# URL du mock Wassup pour provisionner les fiches client à la création d'un device
|
||||
# Laisser vide pour désactiver
|
||||
WASSUP_MOCK_URL: "http://wassup-mock:9999"
|
||||
# Vide = mode permissif : envoie tous les paramètres dans l'Inform (utile pour le dev/test)
|
||||
# Sinon, liste les catégories sémantiques du simulateur (ex: HardwareVersion,SoftwareVersion,...)
|
||||
INFORM_ACCEPTED_PARAMS: ""
|
||||
@@ -85,5 +88,19 @@ services:
|
||||
depends_on:
|
||||
- simulator
|
||||
|
||||
wassup-mock:
|
||||
build:
|
||||
context: ../grelieu/gogowassupgo
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9999:9999"
|
||||
environment:
|
||||
PORT: "9999"
|
||||
DATA_FILE: "/app/data/entries.json"
|
||||
volumes:
|
||||
- wassup_data:/app/data
|
||||
|
||||
volumes:
|
||||
simulator_logs:
|
||||
wassup_data:
|
||||
@@ -148,6 +148,27 @@ function getPropertyValue(name: string): string {
|
||||
const prop = device.properties?.find((p: any) => p.name === name)
|
||||
return prop?.value ?? ''
|
||||
}
|
||||
|
||||
// References resolution
|
||||
const resolvingProvider = ref<string | null>(null)
|
||||
|
||||
async function resolveProvider(providerName: string) {
|
||||
resolvingProvider.value = providerName
|
||||
try {
|
||||
await fetch(`/api/v1/devices/${encodeURIComponent(device.identifier)}/references/resolve/${providerName}`, { method: 'POST' })
|
||||
router.visit(`/devices/${encodeURIComponent(device.identifier)}`, { preserveScroll: true })
|
||||
} catch { /* ignore */ }
|
||||
finally { resolvingProvider.value = null }
|
||||
}
|
||||
|
||||
async function resolveAllProviders() {
|
||||
resolvingProvider.value = '__all__'
|
||||
try {
|
||||
await fetch(`/api/v1/devices/${encodeURIComponent(device.identifier)}/references/resolve`, { method: 'POST' })
|
||||
router.visit(`/devices/${encodeURIComponent(device.identifier)}`, { preserveScroll: true })
|
||||
} catch { /* ignore */ }
|
||||
finally { resolvingProvider.value = null }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -314,6 +335,39 @@ function getPropertyValue(name: string): string {
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Zone 6 : Références client -->
|
||||
<div v-if="device.references && device.references.length > 0" class="rounded-3 px-3 py-3" style="background:#f6f7f9">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 16 16" class="text-muted flex-shrink-0">
|
||||
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z"/>
|
||||
</svg>
|
||||
<span class="text-muted fw-bold text-uppercase" style="font-size:0.62rem;letter-spacing:.08em">Références client</span>
|
||||
<button class="btn btn-link btn-sm p-0 ms-auto" @click="resolveAllProviders" :disabled="resolvingProvider !== null">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2z"/>
|
||||
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-for="ref in device.references" :key="ref.provider" class="mb-2">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<span v-if="ref.status === 'ok'" style="color:#198754">●</span>
|
||||
<span v-else-if="ref.status === 'error'" style="color:#dc3545">●</span>
|
||||
<span v-else style="color:#6c757d">●</span>
|
||||
<span class="fw-semibold small">{{ ref.provider }}</span>
|
||||
<span class="text-muted small">{{ ref.resolvedBy }}</span>
|
||||
<span class="text-muted small ms-auto">{{ ref.resolvedAt ? new Date(ref.resolvedAt).toLocaleString() : '' }}</span>
|
||||
</div>
|
||||
<div v-if="ref.error" class="text-danger small mb-1">{{ ref.error }}</div>
|
||||
<div v-if="ref.status === 'ok' && ref.properties" class="row g-2">
|
||||
<div v-for="(value, key) in ref.properties" :key="key" class="col-sm-6 col-lg-3">
|
||||
<div class="text-muted fw-bold text-uppercase" style="font-size:0.58rem;letter-spacing:.06em">{{ key }}</div>
|
||||
<div class="font-monospace small fw-medium">{{ value || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,13 @@ router
|
||||
router.post('/devices/:identifier/command', [DevicesController, 'executeCommand'])
|
||||
router.post('/devices/:identifier/refresh', [DevicesController, 'refresh'])
|
||||
|
||||
// References
|
||||
const ReferencesController = () => import('#controllers/references_controller')
|
||||
router.get('/devices/:identifier/references', [ReferencesController, 'index'])
|
||||
router.get('/devices/:identifier/references/:provider', [ReferencesController, 'show'])
|
||||
router.post('/devices/:identifier/references/resolve', [ReferencesController, 'resolveAll'])
|
||||
router.post('/devices/:identifier/references/resolve/:provider', [ReferencesController, 'resolveOne'])
|
||||
|
||||
// Commands
|
||||
router.get('/commands', [CommandsController, 'index'])
|
||||
router.get('/commands/:id', [CommandsController, 'show'])
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ReferenceEngine } from '../../../../app/services/references/reference_engine.js'
|
||||
import type { DeviceRecord } from '../../../../app/domain/device/device_record.js'
|
||||
import type { InformContext } from '../../../../app/services/references/types.js'
|
||||
import { DeviceId } from '../../../../app/domain/device/device_id.js'
|
||||
|
||||
function makeDevice(): DeviceRecord {
|
||||
return {
|
||||
identifier: 'test-device-001',
|
||||
deviceId: new DeviceId('TestCorp', 'AABBCC', 'TestBox', 'SERIAL001'),
|
||||
properties: [
|
||||
{ name: 'Device.DeviceInfo.MACAddress', value: 'AA:BB:CC:11:22:33', type: 'xsd:string' as any },
|
||||
],
|
||||
parameters: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('ReferenceEngine.extractResolveValue', () => {
|
||||
const context: InformContext = { events: [], remoteIp: '192.168.1.100', headers: {} }
|
||||
|
||||
it('extracts IP from context', () => {
|
||||
const value = ReferenceEngine.extractResolveValue(makeDevice(), 'ip', context)
|
||||
expect(value).toBe('192.168.1.100')
|
||||
})
|
||||
|
||||
it('extracts serialNumber from device', () => {
|
||||
const value = ReferenceEngine.extractResolveValue(makeDevice(), 'serialNumber', context)
|
||||
expect(value).toBe('SERIAL001')
|
||||
})
|
||||
|
||||
it('extracts OUI from device', () => {
|
||||
const value = ReferenceEngine.extractResolveValue(makeDevice(), 'oui', context)
|
||||
expect(value).toBe('AABBCC')
|
||||
})
|
||||
|
||||
it('extracts MAC from device parameters', () => {
|
||||
const value = ReferenceEngine.extractResolveValue(makeDevice(), 'mac', context)
|
||||
expect(value).toBe('AA:BB:CC:11:22:33')
|
||||
})
|
||||
|
||||
it('extracts parameter by path', () => {
|
||||
const device = makeDevice()
|
||||
device.properties.push({ name: 'Device.ManagementServer.Username', value: 'admin', type: 'xsd:string' as any })
|
||||
const value = ReferenceEngine.extractResolveValue(device, 'parameter:Device.ManagementServer.Username', context)
|
||||
expect(value).toBe('admin')
|
||||
})
|
||||
|
||||
it('returns null for empty IP', () => {
|
||||
const value = ReferenceEngine.extractResolveValue(makeDevice(), 'ip', { events: [], remoteIp: '', headers: {} })
|
||||
expect(value).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for unknown resolveBy', () => {
|
||||
const value = ReferenceEngine.extractResolveValue(makeDevice(), 'unknown_key', context)
|
||||
expect(value).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { WassupProvider } from '../../../../app/services/references/providers/wassup_provider.js'
|
||||
import type { ProviderConfig } from '../../../../app/services/references/types.js'
|
||||
import type { DeviceRecord } from '../../../../app/domain/device/device_record.js'
|
||||
import { DeviceId } from '../../../../app/domain/device/device_id.js'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
|
||||
function makeDevice(): DeviceRecord {
|
||||
return {
|
||||
identifier: 'test-device-001',
|
||||
deviceId: new DeviceId('TestCorp', 'AABBCC', 'TestBox', 'SERIAL001'),
|
||||
properties: [],
|
||||
parameters: [],
|
||||
}
|
||||
}
|
||||
|
||||
function makeConfig(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
|
||||
return {
|
||||
name: 'wassup',
|
||||
type: 'wassup',
|
||||
enabled: true,
|
||||
url: 'http://localhost:19876',
|
||||
timeout: 2000,
|
||||
trigger: ['BOOT'],
|
||||
resolveBy: 'ip',
|
||||
options: { service: 'KRMSCO', requestedProperties: ['sai', 'spr'] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('WassupProvider', () => {
|
||||
let server: Server
|
||||
let responseBody: string
|
||||
let responseStatus: number
|
||||
|
||||
beforeEach(() => {
|
||||
responseBody = 'X_WASSUP_STATUS=OK\nX_WASSUP_SAI=123456789\nX_WASSUP_SPR=FR\n'
|
||||
responseStatus = 200
|
||||
|
||||
server = createServer((_req, res) => {
|
||||
res.writeHead(responseStatus, { 'Content-Type': 'text/plain' })
|
||||
res.end(responseBody)
|
||||
})
|
||||
server.listen(19876)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.close()
|
||||
})
|
||||
|
||||
it('resolves successfully with OK status', async () => {
|
||||
const provider = new WassupProvider(makeConfig())
|
||||
const result = await provider.resolve(makeDevice(), { events: [], remoteIp: '10.0.0.1', headers: {} })
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.status).toBe('ok')
|
||||
expect(result!.provider).toBe('wassup')
|
||||
expect(result!.resolvedBy).toBe('ip:10.0.0.1')
|
||||
expect(result!.properties.sai).toBe('123456789')
|
||||
expect(result!.properties.spr).toBe('FR')
|
||||
})
|
||||
|
||||
it('returns unknown when user not found', async () => {
|
||||
responseBody = 'X_WASSUP_STATUS=UNKNOWN_USER\nX_WASSUP_ERROR=ICS_RESULT_LDAP_ERROR\n'
|
||||
|
||||
const provider = new WassupProvider(makeConfig())
|
||||
const result = await provider.resolve(makeDevice(), { events: [], remoteIp: '10.0.0.99', headers: {} })
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.status).toBe('unknown')
|
||||
expect(result!.properties).toEqual({})
|
||||
})
|
||||
|
||||
it('returns error on HTTP failure', async () => {
|
||||
responseStatus = 500
|
||||
responseBody = 'Internal Server Error'
|
||||
|
||||
const provider = new WassupProvider(makeConfig())
|
||||
const result = await provider.resolve(makeDevice(), { events: [], remoteIp: '10.0.0.1', headers: {} })
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.status).toBe('error')
|
||||
expect(result!.error).toContain('HTTP 500')
|
||||
})
|
||||
|
||||
it('returns error on timeout', async () => {
|
||||
server.close()
|
||||
server = createServer((_req, res) => {
|
||||
// Never respond — simulate timeout
|
||||
setTimeout(() => {
|
||||
res.writeHead(200)
|
||||
res.end('X_WASSUP_STATUS=OK\n')
|
||||
}, 5000)
|
||||
})
|
||||
server.listen(19876)
|
||||
|
||||
const provider = new WassupProvider(makeConfig({ timeout: 100 }))
|
||||
const result = await provider.resolve(makeDevice(), { events: [], remoteIp: '10.0.0.1', headers: {} })
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.status).toBe('error')
|
||||
expect(result!.error).toBe('Timeout')
|
||||
})
|
||||
|
||||
it('returns null when no IP available', async () => {
|
||||
const provider = new WassupProvider(makeConfig())
|
||||
const result = await provider.resolve(makeDevice(), { events: [], remoteIp: '', headers: {} })
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves by serialNumber when configured', async () => {
|
||||
const provider = new WassupProvider(makeConfig({ resolveBy: 'serialNumber' }))
|
||||
const result = await provider.resolve(makeDevice(), { events: [], remoteIp: '', headers: {} })
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.resolvedBy).toBe('serialNumber:SERIAL001')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user