promiseWithTimeout,
secureRandom,
sleep,
+ SupervisionUrlSchema,
watchJsonFile,
} from '../utils/index.js'
import { AutomaticTransactionGenerator } from './AutomaticTransactionGenerator.js'
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
}
/**
- * 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> {
} 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'
)
}
- // 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)
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'
/**
}
/**
- * 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
}
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'
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
}
})
})
+
+ 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()
+ }
+ })
})
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'
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/)
+ })
+ })
})