]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(simulator): encode station identity in WebSocket connection URL (#2177)
authorVitor Coelho <6161176+VitorCoelho@users.noreply.github.com>
Sat, 31 Oct 2026 13:44:31 +0000 (13:44 +0000)
committerGitHub <noreply@github.com>
Sat, 31 Oct 2026 13:44:31 +0000 (13:44 +0000)
* fix(charging-station): percent-encode the station identity in the supervision connection URL

ChargingStation.wsConnectionUrl appended the raw chargingStationId to the
supervision URL, so a reserved character in the identity became URL syntax:
'FRESH#1' turned into the fragment '#1' (ws throws 'The URL contains a
fragment identifier'), 'A?B' into a query and 'A/B' into two path segments.
OCPP-J 1.6 section 3.1.1 requires the identity to be percent-encoded as per
RFC 3986. Encode it with encodeURIComponent when composing the connection
URL only; the raw identity is unchanged everywhere else.

Closes #2176

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(charging-station): cover station identity encoding in the supervision connection URL

Exercise ChargingStation.wsConnectionUrl with identities holding reserved
characters (' ', '%', '/', '?', '#'), through the station info and the OCPP
configuration key paths, and against real TCP and Unix socket WebSocket
servers that must observe a single encoded path segment.

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(ui-web): strip the percent-encoded station identity from the supervision URL

The connection URL published to the UI now ends with the identity encoded
as one path segment, but stripStationId only matched the raw '/<id>'
suffix. SetSupervisionUrlDialog therefore prefilled the full connection
URL as the base URL and a save without editing it (for example a
credentials-only update) stored that as the base and appended the
identity again ('/OCPP16/CS%2B1/CS%2B1').

Match the encoded suffix first and keep the raw one for URLs composed
without encoding.

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(ui-web): cover the supervision URL prefill with an encoded identity

Prefill for 'CS+1', 'CS@1', 'CS 1' and 'FRESH#1', and a credentials-only
save that must resend the base URL unchanged.

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(charging-station): skip the Unix socket identity test on Windows

On Windows the socket path is not a listenable filesystem path
('listen EACCES'), which failed the simulator CI job. Only the Unix
transport case is skipped on win32; the TCP case still runs everywhere.

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(charging-station): delete the stations created by the identity encoding tests

The test file built real ChargingStation instances and never deleted
them, so their BroadcastChannels stayed open and the file only finished
because the CI runner forces exit. Each test now records the stations it
creates and afterEach awaits station.delete(false) for each of them
before the templates are removed. Without --test-force-exit the file
now exits on its own.

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
---------

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
src/charging-station/ChargingStation.ts
tests/charging-station/ChargingStation-WsConnectionUrlIdentity.test.ts [new file with mode: 0644]
ui/web/src/shared/utils/stripStationId.ts
ui/web/tests/unit/skins/modern/Dialogs.test.ts

index 292284bcb5d357a4e7a2689a4e403d9ce5d987ee..14964a08b9bd014b91bbb14d9229639a07641abd 100644 (file)
@@ -273,11 +273,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.
     return this.parseSupervisionUrl(
       `${wsConnectionBaseUrlStr}${
         !wsConnectionBaseUrlStr.endsWith('/') ? '/' : ''
-        // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
-      }${this.stationInfo?.chargingStationId}`,
+      }${encodeURIComponent(String(this.stationInfo?.chargingStationId))}`,
       supervisionUrlField,
       wsConnectionBaseUrlStr
     )
diff --git a/tests/charging-station/ChargingStation-WsConnectionUrlIdentity.test.ts b/tests/charging-station/ChargingStation-WsConnectionUrlIdentity.test.ts
new file mode 100644 (file)
index 0000000..9dbcf22
--- /dev/null
@@ -0,0 +1,178 @@
+/**
+ * @file Tests that the station identity is percent-encoded when appended to the supervision URL.
+ * @description `ChargingStation.wsConnectionUrl` appends the station identity
+ * to the supervision URL as one path segment. OCPP-J 1.6 section 3.1.1
+ * requires that identity to be percent-encoded (RFC 3986), otherwise a
+ * reserved character in it becomes URL syntax: `FRESH#1` turns into a fragment
+ * the WebSocket client refuses, `A?B` into a query, `A/B` into two segments.
+ * The raw identity is preserved everywhere else.
+ */
+import assert from 'node:assert/strict'
+import { createServer } from 'node:http'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+import { WebSocket, WebSocketServer } from 'ws'
+
+import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+
+import { getConfigurationKey } 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'
+import {
+  cleanupStationTemplates,
+  copyStationTemplate,
+  createStationFromTemplate,
+} from './helpers/StationHelpers.realStation.js'
+
+// Shipped templates use this custom key instead of the default ConnectionUrl.
+const OCPP_SUPERVISION_URL_KEY = 'ocppcentraladdress'
+const RESERVED_IDENTITIES: Record<string, string> = {
+  'CS 1': 'CS%201',
+  'CS%1': 'CS%251',
+  'CS/1': 'CS%2F1',
+  'CS?1': 'CS%3F1',
+  'FRESH#1': 'FRESH%231',
+}
+
+const SUPERVISION_URL = `${TEST_SUPERVISION_URL}/OCPP16`
+
+// Every station created by a test, deleted in afterEach before its template
+// is removed, so no station keeps its BroadcastChannel open after the suite.
+let stations: ChargingStation[] = []
+
+const track = (station: ChargingStation): ChargingStation => {
+  stations.push(station)
+  return station
+}
+
+const createStationNamed = (baseName: string, supervisionUrl = SUPERVISION_URL) => {
+  const station = track(
+    createStationFromTemplate(copyStationTemplate(), {
+      baseName,
+      fixedName: true,
+      persistentConfiguration: false,
+    })
+  )
+  // The template ships its own supervision URL; pin the one under test.
+  station.setSupervisionUrl(supervisionUrl)
+  return station
+}
+
+await describe('ChargingStation wsConnectionUrl identity encoding', async () => {
+  beforeEach(() => {
+    stations = []
+  })
+
+  afterEach(async () => {
+    try {
+      for (const station of stations) {
+        await station.delete(false)
+      }
+      await flushMicrotasks()
+    } finally {
+      standardCleanup()
+      cleanupStationTemplates()
+    }
+  })
+
+  await it('should leave an ordinary identity unchanged', () => {
+    const station = createStationNamed(TEST_CHARGING_STATION_BASE_NAME)
+    assert.strictEqual(station.stationInfo?.chargingStationId, TEST_CHARGING_STATION_BASE_NAME)
+    assert.strictEqual(
+      station.wsConnectionUrl.href,
+      `${SUPERVISION_URL}/${TEST_CHARGING_STATION_BASE_NAME}`
+    )
+  })
+
+  await it('should percent-encode reserved characters of the identity as one path segment', () => {
+    for (const [identity, encoded] of Object.entries(RESERVED_IDENTITIES)) {
+      const station = createStationNamed(identity)
+      const url = station.wsConnectionUrl
+      assert.strictEqual(station.stationInfo?.chargingStationId, identity, identity)
+      assert.strictEqual(url.href, `${SUPERVISION_URL}/${encoded}`, identity)
+      assert.strictEqual(url.pathname, `/OCPP16/${encoded}`, identity)
+      assert.strictEqual(url.search, '', identity)
+      assert.strictEqual(url.hash, '', identity)
+      assert.strictEqual(decodeURIComponent(url.pathname.split('/').at(-1) ?? ''), identity)
+    }
+  })
+
+  await it('should encode the identity on the OCPP configuration key branch too', () => {
+    // The key carries the base URL without going through setSupervisionUrl
+    // (template Configuration, or an OCPP ChangeConfiguration); the getter is
+    // the only place that appends the identity to it.
+    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 }
+      )
+    )
+    assert.strictEqual(
+      getConfigurationKey(station, OCPP_SUPERVISION_URL_KEY)?.value,
+      SUPERVISION_URL
+    )
+    assert.strictEqual(station.stationInfo?.chargingStationId, 'FRESH#1')
+    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
+    // the Unix transport case is skipped there, the TCP case runs everywhere.
+    const skip = transport === 'unix' && process.platform === 'win32'
+    await it(
+      `should let a real ${transport} WebSocket server receive one encoded identity segment`,
+      { skip },
+      async () => {
+        const server = createServer()
+        const wss = new WebSocketServer({ server })
+        const seen: string[] = []
+        wss.on('connection', (socket, request) => {
+          seen.push(request.url ?? '')
+          socket.close()
+        })
+        const socketPath = join(tmpdir(), `sim-${String(process.pid)}-${String(Date.now())}.sock`)
+        await new Promise<void>(resolve => {
+          if (transport === 'unix') {
+            server.listen(socketPath, resolve)
+          } else {
+            server.listen(0, '127.0.0.1', resolve)
+          }
+        })
+        try {
+          const address = server.address()
+          const supervisionUrl =
+            transport === 'unix' || address == null || typeof address === 'string'
+              ? `ws+unix://${socketPath}:/OCPP16`
+              : `ws://127.0.0.1:${String(address.port)}/OCPP16`
+          for (const identity of Object.keys(RESERVED_IDENTITIES)) {
+            const station = createStationNamed(identity, supervisionUrl)
+            const ws = new WebSocket(station.wsConnectionUrl)
+            await new Promise<void>((resolve, reject) => {
+              ws.once('open', resolve)
+              ws.once('error', reject)
+            })
+            ws.terminate()
+            assert.strictEqual(seen.at(-1), `/OCPP16/${RESERVED_IDENTITIES[identity]}`, identity)
+          }
+        } finally {
+          wss.close()
+          await new Promise<void>(resolve => {
+            server.close(() => {
+              resolve()
+            })
+          })
+        }
+      }
+    )
+  }
+})
index eb3e7ec6758f833409abd6e8a8d9d48c2d23f686..261ae1193b41dded359850384de9348531afd352 100644 (file)
@@ -1,11 +1,18 @@
 /**
  * Removes a trailing `/<stationId>` suffix from a supervision URL.
+ *
+ * The connection URL published by the simulator ends with the station identity
+ * percent-encoded as one path segment (`encodeURIComponent`), so the encoded
+ * form is matched first; the raw form is kept for URLs that were composed
+ * without encoding.
  * @param url - The supervision URL potentially containing the station ID
  * @param stationId - The station ID to strip
  * @returns The URL without the trailing station ID suffix
  */
 export function stripStationId (url: string, stationId: string): string {
   if (stationId.length === 0) return url
-  const suffix = `/${stationId}`
-  return url.endsWith(suffix) ? url.slice(0, -suffix.length) : url
+  for (const suffix of [`/${encodeURIComponent(stationId)}`, `/${stationId}`]) {
+    if (url.endsWith(suffix)) return url.slice(0, -suffix.length)
+  }
+  return url
 }
index cacaefc9e90c3709f6e41a731e812ff52d191962..bc30cee0f95cf2abe448877200a055c8c8c42287 100644 (file)
@@ -180,6 +180,46 @@ describe('Dialogs', () => {
       expect(input.value).toBe('wss://host:9000')
     })
 
+    it.each([
+      ['CS+1', 'CS%2B1'],
+      ['CS@1', 'CS%401'],
+      ['CS 1', 'CS%201'],
+      ['FRESH#1', 'FRESH%231'],
+    ])(
+      'should prefill URL stripped of the percent-encoded identity %s',
+      (chargingStationId, encodedChargingStationId) => {
+        const wrapper = mountDialog([
+          createChargingStationData({
+            stationInfo: createStationInfo({ chargingStationId }),
+            supervisionUrl: `wss://host:9000/OCPP16/${encodedChargingStationId}`,
+          }),
+        ])
+        const input = wrapper.find<HTMLInputElement>('#modern-sup-url').element
+        expect(input.value).toBe('wss://host:9000/OCPP16')
+      }
+    )
+
+    it('should resend the base URL unchanged when only credentials are edited', async () => {
+      const chargingStationId = 'CS+1'
+      const wrapper = mountDialog([
+        createChargingStationData({
+          started: false,
+          stationInfo: createStationInfo({ chargingStationId }),
+          supervisionUrl: `wss://host:9000/OCPP16/${encodeURIComponent(chargingStationId)}`,
+        }),
+      ])
+      await wrapper.find('#modern-sup-user').setValue('alice')
+      await wrapper.find('#modern-sup-pass').setValue('pw')
+      await wrapper.findAll('.stub-modal__foot button')[1].trigger('click')
+      await flushPromises()
+      expect(mockClient.setSupervisionUrl).toHaveBeenCalledWith(
+        TEST_HASH_ID,
+        'wss://host:9000/OCPP16',
+        'alice',
+        'pw'
+      )
+    })
+
     it('should prefill username and password from stationInfo', () => {
       const wrapper = mountDialog([
         createChargingStationData({