]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(simulator): normalize supervision URL at UI dispatch and station config (#2181)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Tue, 3 Nov 2026 20:21:41 +0000 (21:21 +0100)
committerGitHub <noreply@github.com>
Tue, 3 Nov 2026 20:21:41 +0000 (21:21 +0100)
* fix(simulator): normalize supervision URL at UI dispatch and station config

A SET_SUPERVISION_URL request carrying NBSP or EM SPACE around the scheme was
accepted by the UI payload gate and the raw, untrimmed URL was forwarded to the
charging station. The station's parseSupervisionUrl used a bare `new URL()` that
never trimmed whitespace, so the two layers dialled different URLs.

Both layers now route the URL through the shared SupervisionUrlSchema, which
trims Unicode whitespace and enforces the ws/wss/http/https/ws+unix scheme.

- UI dispatch: replace getRequestPayloadValidationError with
  parseRequestPayloadForDispatch, which overlays the parsed (normalized)
  supervision URL values onto the original payload instead of discarding them.
- Station: parseSupervisionUrl uses SupervisionUrlSchema.safeParse so the
  normalization and scheme validation apply to stored config URLs too.
- Tests: unit tests for parseRequestPayloadForDispatch (NBSP trim, field
  preservation, rejection) and an end-to-end test capturing the value the
  station actually receives.

* test(ui): assert dispatch fires before narrowing in normalization test

The regression test narrowed on the captured payload with early returns,
so a request no longer broadcast would pass silently. Add explicit
assertions that sendRequest fired and carried a payload before the
unknown-object guards, so a lost dispatch now fails the test outright.

* fix(simulator): trim supervision URL before appending station identity

wsConnectionUrl appended the charging-station identity to the configured
supervision URL base before the OCPP configuration value was normalized.
Trailing NBSP/EM SPACE (U+00A0/U+2003) — which the WHATWG URL parser does
not trim — became internal whitespace percent-encoded into the dialed path
for example OCPP16%C2%A0/identity.

Trim the base URL before appending the identity; the derived URL is still
validated by parseSupervisionUrl. Add a regression test asserting both a
trailing NBSP and a trailing EM SPACE are trimmed instead of encoded.

* test(ui): assert malformed dispatch payloads fail explicitly

The supervision-URL normalization regression test narrowed the dispatched
payload with early `if (...) return` guards, so a payload that was not a
non-null object or was missing the `url` property silently passed without
ever checking the normalization.

Replace those narrowing guards with explicit `assert.fail(...)` checks that
fail the test outright, matching the repo's own guard idiom, so a lost or
malformed dispatch is no longer masked.

* test(ui): cover normalization of station option URLs

* docs(simulator): clarify supervision URL contracts and test descriptions

src/charging-station/ChargingStation.ts
src/charging-station/ui-server/ui-services/AbstractUIService.ts
src/charging-station/ui-server/ui-services/UIServiceRequestPayloadSchemas.ts
tests/charging-station/ChargingStation-WsConnectionUrlIdentity.test.ts
tests/charging-station/ui-server/ui-services/AbstractUIService.test.ts
tests/charging-station/ui-server/ui-services/UIServiceRequestPayloadSchemas.test.ts

index 14964a08b9bd014b91bbb14d9229639a07641abd..a4ca743300977626e98987708f6bfe746df92437 100644 (file)
@@ -118,6 +118,7 @@ import {
   promiseWithTimeout,
   secureRandom,
   sleep,
+  SupervisionUrlSchema,
   watchJsonFile,
 } from '../utils/index.js'
 import { AutomaticTransactionGenerator } from './AutomaticTransactionGenerator.js'
@@ -273,13 +274,14 @@ export class ChargingStation extends EventEmitter {
       supervisionUrlOcppKey != null && isNotEmptyString(configuredSupervisionUrl)
         ? supervisionUrlOcppKey
         : 'supervisionUrls'
-    // OCPP-J 1.6 section 3.1.1: the identity is one path segment and must be
-    // percent-encoded (RFC 3986), otherwise a reserved character in it becomes
-    // URL syntax ('#' a fragment, '?' a query, '/' another segment). The raw
-    // identity is kept everywhere else; only the connection URL is encoded.
+    // OCPP-J 1.6 §3.1.1: percent-encode the identity as one path segment;
+    // preserve its raw value elsewhere.
+    // Trim the base before appending the identity to keep trailing spaces
+    // (including NBSP/EM SPACE) out of the request path.
+    const wsConnectionBaseUrl = wsConnectionBaseUrlStr.trim()
     return this.parseSupervisionUrl(
-      `${wsConnectionBaseUrlStr}${
-        !wsConnectionBaseUrlStr.endsWith('/') ? '/' : ''
+      `${wsConnectionBaseUrl}${
+        !wsConnectionBaseUrl.endsWith('/') ? '/' : ''
       }${encodeURIComponent(String(this.stationInfo?.chargingStationId))}`,
       supervisionUrlField,
       wsConnectionBaseUrlStr
@@ -3753,31 +3755,33 @@ export class ChargingStation extends EventEmitter {
   }
 
   /**
-   * Parses a supervision URL, raising a structured configuration error naming
-   * the offending field and station instead of leaking the `TypeError` thrown
-   * by `new URL()`. The original error is logged with its stack.
-   * @param supervisionUrl - Supervision URL string to parse.
-   * @param field - Configuration field or OCPP configuration key holding it.
-   * @param reportedUrl - Configured value quoted in the diagnostic; defaults to
-   *   `supervisionUrl`. The `wsConnectionUrl` getter passes the configured base
-   *   URL because it parses a derived URL (base plus charging station identity).
-   * @returns The parsed supervision URL.
+   * Validates and normalizes a supervision URL with the shared schema.
+   * Validation failures log the Zod error and throw a BaseError identifying the
+   * configuration field and, when available, the station.
+   * @param supervisionUrl - URL to validate and normalize.
+   * @param field - Configuration field or OCPP key used in diagnostics.
+   * @param reportedUrl - Value quoted in errors; defaults to `supervisionUrl`.
+   * @returns Validated, normalized supervision URL.
+   * @throws {BaseError} If the supervision URL fails validation.
    */
   private parseSupervisionUrl (
     supervisionUrl: string,
     field: string,
     reportedUrl = supervisionUrl
   ): URL {
-    try {
-      return new URL(supervisionUrl)
-    } catch (error) {
+    const result = SupervisionUrlSchema.safeParse(supervisionUrl)
+    if (!result.success) {
       const chargingStationId = this.stationInfo?.chargingStationId
       const errorMsg = `${
         isNotEmptyString(chargingStationId) ? `${chargingStationId}: ` : ''
       }Invalid supervision url '${reportedUrl}' configured in '${field}'`
-      logger.error(`${this.logPrefix()} ${moduleName}.parseSupervisionUrl: ${errorMsg}`, error)
+      logger.error(
+        `${this.logPrefix()} ${moduleName}.parseSupervisionUrl: ${errorMsg}`,
+        result.error
+      )
       throw new BaseError(errorMsg)
     }
+    return new URL(result.data)
   }
 
   private async performDelete (): Promise<void> {
index f2b614e3e26495e3127898e530f0d89bed8c6b55..f75b834752003b946f7614a051d28a523116c8ee 100644 (file)
@@ -35,7 +35,7 @@ import {
 } from '../../../utils/index.js'
 import { UIServiceWorkerBroadcastChannel } from '../../broadcast-channel/UIServiceWorkerBroadcastChannel.js'
 import { DEFAULT_MAX_STATIONS, isValidNumberOfStations } from '../UIServerSecurity.js'
-import { getRequestPayloadValidationError } from './UIServiceRequestPayloadSchemas.js'
+import { parseRequestPayloadForDispatch } from './UIServiceRequestPayloadSchemas.js'
 
 const moduleName = 'AbstractUIService'
 
@@ -236,19 +236,17 @@ export abstract class AbstractUIService {
         )
       }
 
-      // Transport schemas may validate a different envelope; enforce the shared
-      // flat-payload contract before dispatching to stations.
-      const validationError = getRequestPayloadValidationError(command, requestPayload)
-      if (validationError != null) {
-        throw new BaseError(`'${command}' request payload is invalid: ${validationError}`)
+      // Validate the flat payload independently of transport envelope schemas.
+      const validatedPayload = parseRequestPayloadForDispatch(command, requestPayload)
+      if (validatedPayload.error != null) {
+        throw new BaseError(`'${command}' request payload is invalid: ${validatedPayload.error}`)
       }
 
-      // Call the request handler to build the response payload
       const requestHandler = this.requestHandlers.get(command)
       if (requestHandler == null) {
         throw new BaseError(`'${command}' request handler not found`)
       }
-      responsePayload = await requestHandler(uuid, command, requestPayload, context)
+      responsePayload = await requestHandler(uuid, command, validatedPayload.payload, context)
     } catch (error) {
       // Log
       logger.error(`${this.logPrefix(moduleName, 'requestHandler')} Handle request error:`, error)
index 2e30ea8af17b26e91ae83d1a5ff03ba541cda747..836d488116303ed9dc0118f2eb430048a18f4e4c 100644 (file)
@@ -10,7 +10,7 @@
 
 import { z } from 'zod'
 
-import { ProcedureName } from '../../../types/index.js'
+import { ProcedureName, type RequestPayload } from '../../../types/index.js'
 import { isEmpty, SupervisionUrlSchema } from '../../../utils/index.js'
 
 /**
@@ -318,17 +318,57 @@ const formatIssues = (issues: readonly z.core.$ZodIssue[]): string => {
 }
 
 /**
- * Validates the procedure payload without transforming it: handlers rely on
- * optional-field presence and unlisted PDU fields remaining unchanged.
- * @param procedureName - Procedure the payload is destined for.
- * @param requestPayload - Untrusted payload, as received from the transport.
- * @returns `undefined` when the payload is valid, otherwise a single-line
- * description of every violation.
+ * Original payload with parsed supervision URLs, or grouped validation issues.
  */
-export const getRequestPayloadValidationError = (
+export type ValidatedRequestPayload =
+  { error: string; payload?: undefined } | { error?: undefined; payload: RequestPayload }
+
+/**
+ * Validates a UI payload and overlays schema-parsed supervision URLs.
+ * Preserves unlisted PDU fields and optional-field presence.
+ * @param procedureName - Target procedure.
+ * @param requestPayload - Untrusted payload received from the transport.
+ * @returns Validation issues or the payload ready for dispatch.
+ */
+export const parseRequestPayloadForDispatch = (
   procedureName: ProcedureName,
-  requestPayload: unknown
-): string | undefined => {
+  requestPayload: RequestPayload
+): ValidatedRequestPayload => {
   const result = uiServiceRequestPayloadSchemas[procedureName].safeParse(requestPayload)
-  return result.success ? undefined : formatIssues(result.error.issues)
+  if (!result.success) {
+    return { error: formatIssues(result.error.issues) }
+  }
+  return {
+    payload: overlayNormalizedSupervisionUrls(
+      requestPayload,
+      result.data as Record<string, unknown>
+    ),
+  }
+}
+
+/**
+ * Copies the payload with schema-parsed supervision URLs.
+ * @param requestPayload - Original transport payload.
+ * @param parsed - Validated schema output.
+ * @returns Payload copy with only supervision URL fields replaced.
+ */
+const overlayNormalizedSupervisionUrls = (
+  requestPayload: RequestPayload,
+  parsed: Record<string, unknown>
+): RequestPayload => {
+  const override: Record<string, unknown> = { ...requestPayload }
+  const source = requestPayload as { options?: Record<string, unknown>; url?: unknown }
+  const parsedData = parsed as { options?: { supervisionUrls?: unknown }; url?: unknown }
+  // SET_SUPERVISION_URL: parsed single URL.
+  if (typeof source.url === 'string' && typeof parsedData.url === 'string') {
+    override.url = parsedData.url
+  }
+  // ADD_CHARGING_STATIONS: parsed URL or URL array.
+  if (source.options?.supervisionUrls != null) {
+    override.options = {
+      ...source.options,
+      supervisionUrls: parsedData.options?.supervisionUrls ?? source.options.supervisionUrls,
+    }
+  }
+  return override as RequestPayload
 }
index 9dbcf22060506ae5c1e2d050a53bd54c796c4bf3..c3f5cc82efa2aa129e78d21351c9ebb9283f2714 100644 (file)
@@ -16,7 +16,10 @@ import { WebSocket, WebSocketServer } from 'ws'
 
 import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
 
-import { getConfigurationKey } from '../../src/charging-station/ConfigurationKeyUtils.js'
+import {
+  getConfigurationKey,
+  setConfigurationKeyValue,
+} from '../../src/charging-station/ConfigurationKeyUtils.js'
 import { flushMicrotasks, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
 import { TEST_SUPERVISION_URL } from '../utils/TestNetworkConstants.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from './ChargingStationTestConstants.js'
@@ -124,6 +127,31 @@ await describe('ChargingStation wsConnectionUrl identity encoding', async () =>
     assert.strictEqual(station.stationInfo?.chargingStationId, 'FRESH#1')
     assert.strictEqual(station.wsConnectionUrl.href, `${SUPERVISION_URL}/FRESH%231`)
   })
+  await it('should trim trailing Unicode spaces from the OCPP base URL', () => {
+    const station = track(
+      createStationFromTemplate(
+        copyStationTemplate({
+          Configuration: {
+            configurationKey: [
+              { key: OCPP_SUPERVISION_URL_KEY, readonly: false, value: SUPERVISION_URL },
+            ],
+          },
+          supervisionUrlOcppConfiguration: true,
+          supervisionUrlOcppKey: OCPP_SUPERVISION_URL_KEY,
+        }),
+        { baseName: 'FRESH#1', fixedName: true, persistentConfiguration: false }
+      )
+    )
+    // Store the padded base URL directly, bypassing setSupervisionUrl.
+    setConfigurationKeyValue(station, OCPP_SUPERVISION_URL_KEY, `${SUPERVISION_URL}\u00A0`)
+    assert.ok(
+      !/%C2%A0|%E2%80%83/.test(station.wsConnectionUrl.href),
+      'trailing whitespace must not be percent-encoded into the dialed path'
+    )
+    assert.strictEqual(station.wsConnectionUrl.href, `${SUPERVISION_URL}/FRESH%231`)
+    setConfigurationKeyValue(station, OCPP_SUPERVISION_URL_KEY, `${SUPERVISION_URL}\u2003`)
+    assert.strictEqual(station.wsConnectionUrl.href, `${SUPERVISION_URL}/FRESH%231`)
+  })
 
   for (const transport of ['tcp', 'unix'] as const) {
     // Unix domain sockets are not a filesystem path on Windows (`listen EACCES`); only
index f567afa23a5a5182d2fa6fa0766da4aaa8567720..e4a216e85cdec93eb9c6ee9b318ef136efc1983d 100644 (file)
@@ -1047,4 +1047,47 @@ await describe('AbstractUIService', async () => {
       }
     })
   })
+
+  await it('should pass the trimmed supervision URL to the worker channel', async t => {
+    const { service } = createServiceContext()
+    const channel = Reflect.get(
+      service,
+      'uiServiceWorkerBroadcastChannel'
+    ) as UIServiceWorkerBroadcastChannel
+
+    // Capture the payload from the worker-channel [uuid, procedureName, payload] tuple.
+    let receivedPayload: unknown
+    const sendRequest = t.mock.method(channel, 'sendRequest', (...args: unknown[]) => {
+      const tuple = args.at(-1)
+      if (Array.isArray(tuple)) {
+        receivedPayload = tuple[2]
+      }
+    })
+
+    try {
+      const response = await service.requestHandler(
+        createProtocolRequest(TEST_UUID, ProcedureName.SET_SUPERVISION_URL, {
+          hashIds: [TEST_HASH_ID],
+          url: '\u00A0ws://localhost:9999/OCPP16\u00A0',
+        })
+      )
+
+      // Broadcast responses are deferred.
+      assert.strictEqual(response, undefined)
+
+      // Require a dispatched payload containing the trimmed URL.
+      assert.ok(sendRequest.mock.calls.length)
+      if (typeof receivedPayload !== 'object' || receivedPayload == null) {
+        assert.fail('dispatched payload must be a non-null object payload')
+      }
+      if (!('url' in receivedPayload)) {
+        assert.fail('dispatched payload must carry a "url" property')
+      }
+
+      assert.strictEqual(receivedPayload.url, 'ws://localhost:9999/OCPP16')
+      assert.notEqual(receivedPayload.url, '\u00A0ws://localhost:9999/OCPP16\u00A0')
+    } finally {
+      service.stop()
+    }
+  })
 })
index c1ee0728cf64326ca92048c2ab46c411465612fe..713ba136120941b13c7e279706b5b203b24fcbe6 100644 (file)
@@ -14,6 +14,7 @@ import type {
   RequestPayload,
 } from '../../../../src/types/index.js'
 
+import { parseRequestPayloadForDispatch } from '../../../../src/charging-station/ui-server/ui-services/UIServiceRequestPayloadSchemas.js'
 import { ProcedureName, ResponseStatus } from '../../../../src/types/index.js'
 import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_HASH_ID, TEST_UUID } from '../UIServerTestConstants.js'
@@ -989,4 +990,88 @@ await describe('UIServiceRequestPayloadSchemas', async () => {
       service.stop()
     }
   })
+
+  await describe('parseRequestPayloadForDispatch', async () => {
+    await it('should trim NBSP from the supervision URL', () => {
+      const result = parseRequestPayloadForDispatch(ProcedureName.SET_SUPERVISION_URL, {
+        hashIds: [TEST_HASH_ID],
+        url: '\u00A0ws://localhost:9999/OCPP16\u00A0',
+      })
+
+      assert.strictEqual(result.error, undefined)
+      assert.notStrictEqual(result.payload, undefined)
+
+      assert.strictEqual(result.payload.url, 'ws://localhost:9999/OCPP16')
+    })
+
+    for (const scenario of [
+      {
+        expectedUrls: 'ws://localhost:9999/OCPP16',
+        name: 'string',
+        supervisionUrls: '\u00A0ws://localhost:9999/OCPP16\u2003',
+      },
+      {
+        expectedUrls: ['ws://localhost:9999/OCPP16', 'wss://localhost:9443/OCPP16'],
+        name: 'array',
+        supervisionUrls: [
+          '\u00A0ws://localhost:9999/OCPP16\u2003',
+          '\u2003wss://localhost:9443/OCPP16\u00A0',
+        ],
+      },
+    ]) {
+      await it(`should trim ${scenario.name} station option URLs without mutating the input`, () => {
+        const payload = {
+          numberOfStations: 1,
+          options: {
+            diagnostic: { source: 'operator' },
+            fixedName: false,
+            supervisionUrls: scenario.supervisionUrls,
+          },
+          template: 'test.station-template',
+        }
+        const originalPayload = structuredClone(payload)
+
+        const result = parseRequestPayloadForDispatch(ProcedureName.ADD_CHARGING_STATIONS, payload)
+
+        assert.strictEqual(result.error, undefined)
+        assert.deepStrictEqual(result.payload, {
+          numberOfStations: 1,
+          options: {
+            diagnostic: { source: 'operator' },
+            fixedName: false,
+            supervisionUrls: scenario.expectedUrls,
+          },
+          template: 'test.station-template',
+        })
+        assert.deepStrictEqual(payload, originalPayload)
+      })
+    }
+
+    await it('should preserve hashIds for a valid supervision URL', () => {
+      const result = parseRequestPayloadForDispatch(ProcedureName.SET_SUPERVISION_URL, {
+        hashIds: [TEST_HASH_ID],
+        url: 'ws://localhost:9999/OCPP16',
+      })
+
+      assert.strictEqual(result.error, undefined)
+      assert.notStrictEqual(result.payload, undefined)
+
+      assert.deepStrictEqual(result.payload, {
+        hashIds: [TEST_HASH_ID],
+        url: 'ws://localhost:9999/OCPP16',
+      })
+    })
+
+    await it('should reject a malformed supervision URL', () => {
+      const result = parseRequestPayloadForDispatch(ProcedureName.SET_SUPERVISION_URL, {
+        hashIds: [TEST_HASH_ID],
+        url: 'not-a-url',
+      })
+
+      assert.notStrictEqual(result.error, undefined)
+      if (result.error == null) return
+
+      assert.match(result.error, /url/)
+    })
+  })
 })