From: Vitor Coelho <6161176+VitorCoelho@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:44:31 +0000 (+0000) Subject: fix(simulator): encode station identity in WebSocket connection URL (#2177) X-Git-Tag: cli@v5.0.0~13 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=02009d08eb4be9d1ace5a6b6e16c1f85e1eb6d00;p=e-mobility-charging-stations-simulator.git fix(simulator): encode station identity in WebSocket connection URL (#2177) * 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 * 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 * 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 '/' 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 * 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 * 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 * 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 --------- Signed-off-by: VitorCoelho --- diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 292284bc..14964a08 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -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 index 00000000..9dbcf220 --- /dev/null +++ b/tests/charging-station/ChargingStation-WsConnectionUrlIdentity.test.ts @@ -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 = { + '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(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((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(resolve => { + server.close(() => { + resolve() + }) + }) + } + } + ) + } +}) diff --git a/ui/web/src/shared/utils/stripStationId.ts b/ui/web/src/shared/utils/stripStationId.ts index eb3e7ec6..261ae119 100644 --- a/ui/web/src/shared/utils/stripStationId.ts +++ b/ui/web/src/shared/utils/stripStationId.ts @@ -1,11 +1,18 @@ /** * Removes a trailing `/` 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 } diff --git a/ui/web/tests/unit/skins/modern/Dialogs.test.ts b/ui/web/tests/unit/skins/modern/Dialogs.test.ts index cacaefc9..bc30cee0 100644 --- a/ui/web/tests/unit/skins/modern/Dialogs.test.ts +++ b/ui/web/tests/unit/skins/modern/Dialogs.test.ts @@ -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('#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({