From: Jérôme Benoit Date: Fri, 6 Nov 2026 15:52:33 +0000 (+0100) Subject: fix(simulator): validate template supervision URLs with the shared endpoint policy... X-Git-Tag: cli@v5.0.0~5 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=36365546c6a49c6d3f4184cc0f5af8eba97a46f0;p=e-mobility-charging-stations-simulator.git fix(simulator): validate template supervision URLs with the shared endpoint policy (#2185) * fix(simulator): validate template supervision URLs with the shared endpoint policy Templates accepted arbitrary strings in supervisionUrls: a value the supervision transport cannot dial (e.g. ftp://, a fragment, a query) passed template validation and only failed at WebSocket connection time with a bare protocol SyntaxError naming neither the field nor the template. Narrow BaseTemplateSchema.supervisionUrls (string or string[]) to the shared SupervisionUrlSchema - the same policy as configuration files (ConfigurationSchema) and runtime UI payloads (ChargingStationConfigurationSchema) - and bump the template schema version to v2 with an identity migration: the narrowing is a validation concern, and a v1 template carrying a value the transport cannot dial has no migrated form, so it is rejected with a field-level diagnostic. All shipped station templates are bumped to v2. Rejected values never reach a live station: template loading fails before construction, and the consumer-side policy (#2181) is unchanged. Tests cover the shared sub-schema (schemes, query/fragment, ws+unix form, whitespace trimming), the v1 -> v2 identity hop, v1 template rejection (field naming, migration note) and migration of a valid v1 template, shipped-template round-trips, and real-station coverage of the template and OCPP-key seeding paths plus setSupervisionUrl. * fix(simulator): validate seeded OCPP supervision key at template load A template seeding the active supervision OCPP key (Configuration.configurationKey entry matching supervisionUrlOcppKey) with a value the transport cannot dial now fails at template load, naming the key, instead of constructing a station that fails at first connection. Empty values remain inert (the consumer ignores them). The shared policy error text is aligned to the 'supervision URL' terminology used across the codebase, and the README cell harmonizes URIs to URLs. * fix(simulator): apply OCPP 2.0.x key resolution to the seeded supervision key check The consumer resolves supervisionUrlOcppKey before matching configuration entries (OCPP 2.0.x stations store the mapped 1.6 standard keys under their 2.0.1 namespaced variable name). The template-load check now applies the same resolution via the extracted resolveOcppParameterKey, closing the fail-fast gap for namespaced entries and removing a false positive for un-resolved spellings. The README supervisionUrlOcppKey row documents the seeded-value constraint, and isOCPP20x accepts a plain version string. * [autofix.ci] apply automated fixes * fix(simulator): validate only the first matching entry of the seeded supervision OCPP key The consumer dials only the first configuration key entry matching the supervision key (later duplicates are ignored, and an empty first value falls back to the configured supervision URL). The template-load check now mirrors that lookup exactly instead of validating every match, which rejected templates the station would have dialed correctly. Suggested by the repository PR-bot review comment on TemplateSchema. * fix(simulator): extend supervision URL policy to the effective key and 2.0.1 writes - Validate the effective OCPP key at template load: an omitted supervisionUrlOcppKey falls back to the default ConnectionUrl, so the seeded value for that key is checked like any other active key. - Reject SetVariables writes that point the seeded supervision OCPP key at a value the transport cannot dial, mirroring the OCPP 1.6 ChangeConfiguration guard; isSupportedSupervisionUrl moves to ConfigurationKeyUtils so both services share one predicate. - README: align the seeded-value clause with the consumer semantics (first matching entry; empty values are ignored). * [autofix.ci] apply automated fixes * fix(simulator): apply the raw-value supervision guard to the seeded OCPP key SupervisionUrlSchema normalizes whitespace and control characters, but the OCPP key stores the value as sent and the consumer dials it raw. The seeded check now applies the shared isSupportedSupervisionUrl predicate before schema parsing so a padded or control-character value is rejected like the equivalent runtime write (ChangeConfiguration / SetVariables), naming the raw-character rule when the schema itself would accept the normalized form. * test(simulator): pin supervision guard coverage boundaries and correct its documentation - Correct the raw-value guard documentation: the consumer trims the base before appending the station identity, so the raw check is deliberate input hygiene stricter than the dial path, not a dial constraint. - Document the 2.0.1 guard coverage boundary: unmapped vendor keys and the BootNotification heartbeat-interval write are inert by design; the fail-closed dial-time parse remains the net for both. - Tests: pin the guard's inert branches (empty key, unmapped vendor key), restore dial-time coverage for a non-dialable value from a non-template source (persisted file / manual edit), and assert the template file name in the load error. * fix(charging-station): unify supervision OCPP key active check and correct audit findings Add the isSupervisionOcppKeyActive predicate and migrate the five call sites that each rewrote the active-key condition (consumer getter, setSupervisionUrl, constructor re-seed, 1.6 ChangeConfiguration guard, 2.0.1 SetVariables guard). The 1.6 guard now also requires a non-empty key, aligning it with the consumer, the 2.0.1 guard and the template-load check: a station whose supervision key is empty never dials the key, so a write to it is inert instead of rejected. Correct the audit findings in the written artifacts: resolveOcppParameterKey maps 1.6 keys (standard and vendor) so drop the imprecise 'standard' qualification and reference the resolver instead of re-explaining it; state the 2.0.1 guard matching condition exactly (a key matches a component-scoped write key only when its resolved spelling is namespaced); widen the isSupportedSupervisionUrl JSDoc scope (it guards runtime writes as well as the seeded value); reference the raw-value guard rationale from its authoritative JSDoc instead of re-explaining it in three places; use the canonical 'supervision' terminology in the README supervisionUrls row and the two 'shared supervision endpoint policy' mention sites; rename the padded-value test case to match what it exercises. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- diff --git a/README.md b/README.md index 0b228549..bd894785 100644 --- a/README.md +++ b/README.md @@ -206,11 +206,11 @@ The `/configurations` subdirectory is created by the runtime when pe | Key | Value(s) | Default Value | Value type | Description | | ---------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| supervisionUrls | | [] | string \| string[] | string or strings array containing connection URIs to OCPP-J servers | +| supervisionUrls | | [] | string \| string[] | string or strings array of OCPP-J server endpoints used for supervision. Each value must be a supervision URL the transport can dial: `ws:`, `wss:`, `http:`, `https:` or `ws+unix:` scheme, no query string or fragment (shared supervision endpoint policy) | | supervisionUser | | undefined | string | basic HTTP authentication user to OCPP-J server | | supervisionPassword | | undefined | string | basic HTTP authentication password to OCPP-J server | | supervisionUrlOcppConfiguration | true/false | false | boolean | enable supervision URL configuration via a vendor OCPP parameter key | -| supervisionUrlOcppKey | | 'ConnectionUrl' | string | the vendor string that will be used as a vendor OCPP parameter key to set the supervision URL | +| supervisionUrlOcppKey | | 'ConnectionUrl' | string | the vendor string that will be used as a vendor OCPP parameter key to set the supervision URL. The seeded value of the first matching entry must satisfy the shared supervision endpoint policy at template load; empty values are ignored by the consumer | | autoStart | true/false | true | boolean | enable automatic start of added charging station from template | | ocppVersion | 1.6/2.0/2.0.1 | 1.6 | string | OCPP version | | ocppProtocol | json | json | string | OCPP protocol | diff --git a/cspell.config.yaml b/cspell.config.yaml index 7735b6b6..1046d38f 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -104,6 +104,8 @@ words: - emerg - REMAPPINGS - interruptible + - dialable + - dialability - HSTS - Hsts - hsts diff --git a/src/assets/station-templates/abb-atg.station-template.json b/src/assets/station-templates/abb-atg.station-template.json index 835333ca..ca2bebd2 100644 --- a/src/assets/station-templates/abb-atg.station-template.json +++ b/src/assets/station-templates/abb-atg.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "idTagsFile": "idtags.json", "baseName": "CS-ABB", "chargePointModel": "MD_TERRA_53", diff --git a/src/assets/station-templates/abb.station-template.json b/src/assets/station-templates/abb.station-template.json index d8d1523b..0afb774e 100644 --- a/src/assets/station-templates/abb.station-template.json +++ b/src/assets/station-templates/abb.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "idTagsFile": "idtags.json", "baseName": "CS-ABB", "chargePointModel": "MD_TERRA_53", diff --git a/src/assets/station-templates/chargex.station-template.json b/src/assets/station-templates/chargex.station-template.json index 95875f21..a3ec5667 100644 --- a/src/assets/station-templates/chargex.station-template.json +++ b/src/assets/station-templates/chargex.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "idTagsFile": "idtags.json", "baseName": "CS-CHARGEX", "chargePointModel": "Aqueduct 1.0", diff --git a/src/assets/station-templates/evlink.station-template.json b/src/assets/station-templates/evlink.station-template.json index 1e72929b..e5387c4c 100644 --- a/src/assets/station-templates/evlink.station-template.json +++ b/src/assets/station-templates/evlink.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "supervisionUrlOcppConfiguration": true, "supervisionUrlOcppKey": "ocppcentraladdress", "idTagsFile": "idtags.json", diff --git a/src/assets/station-templates/keba-ocpp2-signed.station-template.json b/src/assets/station-templates/keba-ocpp2-signed.station-template.json index f9bed001..d219cf4e 100644 --- a/src/assets/station-templates/keba-ocpp2-signed.station-template.json +++ b/src/assets/station-templates/keba-ocpp2-signed.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "supervisionUrls": ["ws://localhost:9000"], "supervisionUrlOcppConfiguration": true, "supervisionUrlOcppKey": "CentralSystemAddress", diff --git a/src/assets/station-templates/keba-ocpp2.station-template.json b/src/assets/station-templates/keba-ocpp2.station-template.json index 71c3c9d4..47ee2084 100644 --- a/src/assets/station-templates/keba-ocpp2.station-template.json +++ b/src/assets/station-templates/keba-ocpp2.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "supervisionUrls": ["ws://localhost:9000"], "supervisionUrlOcppConfiguration": true, "supervisionUrlOcppKey": "CentralSystemAddress", diff --git a/src/assets/station-templates/keba.station-template.json b/src/assets/station-templates/keba.station-template.json index a306612a..a2335ef0 100644 --- a/src/assets/station-templates/keba.station-template.json +++ b/src/assets/station-templates/keba.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "supervisionUrlOcppConfiguration": true, "supervisionUrlOcppKey": "CentralSystemAddress", "idTagsFile": "idtags.json", diff --git a/src/assets/station-templates/schneider-evses.station-template.json b/src/assets/station-templates/schneider-evses.station-template.json index 9962b922..32385251 100644 --- a/src/assets/station-templates/schneider-evses.station-template.json +++ b/src/assets/station-templates/schneider-evses.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "supervisionUrlOcppConfiguration": true, "supervisionUrlOcppKey": "ocppcentraladdress", "idTagsFile": "idtags.json", diff --git a/src/assets/station-templates/schneider-imredd.station-template.json b/src/assets/station-templates/schneider-imredd.station-template.json index 586183b8..b51f3c89 100644 --- a/src/assets/station-templates/schneider-imredd.station-template.json +++ b/src/assets/station-templates/schneider-imredd.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "supervisionUrlOcppConfiguration": true, "supervisionUrlOcppKey": "ocppcentraladdress", "idTagsFile": "idtags.json", diff --git a/src/assets/station-templates/schneider.station-template.json b/src/assets/station-templates/schneider.station-template.json index 05983b9d..3b07e078 100644 --- a/src/assets/station-templates/schneider.station-template.json +++ b/src/assets/station-templates/schneider.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "supervisionUrlOcppConfiguration": true, "supervisionUrlOcppKey": "ocppcentraladdress", "idTagsFile": "idtags.json", diff --git a/src/assets/station-templates/siemens.station-template.json b/src/assets/station-templates/siemens.station-template.json index ddd5a30a..20eb6fc1 100644 --- a/src/assets/station-templates/siemens.station-template.json +++ b/src/assets/station-templates/siemens.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "idTagsFile": "idtags.json", "baseName": "CS-SIEMENS", "fixedName": true, diff --git a/src/assets/station-templates/virtual-simple-atg.station-template.json b/src/assets/station-templates/virtual-simple-atg.station-template.json index 2b0017ed..29fd6d9d 100644 --- a/src/assets/station-templates/virtual-simple-atg.station-template.json +++ b/src/assets/station-templates/virtual-simple-atg.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "idTagsFile": "idtags.json", "baseName": "CS-BASIC", "chargePointModel": "Simulator simple", diff --git a/src/assets/station-templates/virtual-simple-signed.station-template.json b/src/assets/station-templates/virtual-simple-signed.station-template.json index f04c9911..8b50c44f 100644 --- a/src/assets/station-templates/virtual-simple-signed.station-template.json +++ b/src/assets/station-templates/virtual-simple-signed.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "idTagsFile": "idtags.json", "baseName": "CS-BASIC-SIGNED", "chargePointModel": "Simulator simple", diff --git a/src/assets/station-templates/virtual-simple.station-template.json b/src/assets/station-templates/virtual-simple.station-template.json index 74f10265..cafe1830 100644 --- a/src/assets/station-templates/virtual-simple.station-template.json +++ b/src/assets/station-templates/virtual-simple.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "idTagsFile": "idtags.json", "baseName": "CS-BASIC", "chargePointModel": "Simulator simple", diff --git a/src/assets/station-templates/virtual.station-template.json b/src/assets/station-templates/virtual.station-template.json index 27cd659e..81d75fc1 100644 --- a/src/assets/station-templates/virtual.station-template.json +++ b/src/assets/station-templates/virtual.station-template.json @@ -1,5 +1,5 @@ { - "$schemaVersion": 1, + "$schemaVersion": 2, "idTagsFile": "idtags.json", "baseName": "CS-SIMU", "chargePointModel": "Simulator connectors", diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index a4ca7433..5bd26b0d 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -129,6 +129,7 @@ import { buildConfigKey, deleteConfigurationKey, getConfigurationKey, + isSupervisionOcppKeyActive, setConfigurationKeyValue, warnOnOCPP16TemplateKeys, } from './ConfigurationKeyUtils.js' @@ -255,11 +256,9 @@ export class ChargingStation extends EventEmitter { } public get wsConnectionUrl (): URL { - const supervisionUrlOcppKey = - this.stationInfo?.supervisionUrlOcppConfiguration === true && - isNotEmptyString(this.stationInfo.supervisionUrlOcppKey) - ? this.stationInfo.supervisionUrlOcppKey - : undefined + const supervisionUrlOcppKey = isSupervisionOcppKeyActive(this) + ? this.stationInfo.supervisionUrlOcppKey + : undefined const configuredSupervisionUrl = supervisionUrlOcppKey != null ? getConfigurationKey(this, supervisionUrlOcppKey)?.value @@ -1866,10 +1865,7 @@ export class ChargingStation extends EventEmitter { target.supervisionPassword = supervisionPassword } } - if ( - this.stationInfo?.supervisionUrlOcppConfiguration === true && - isNotEmptyString(this.stationInfo.supervisionUrlOcppKey) - ) { + if (isSupervisionOcppKeyActive(this)) { const supervisionUrl = this.parseSupervisionUrl(url, this.stationInfo.supervisionUrlOcppKey) setConfigurationKeyValue(this, this.stationInfo.supervisionUrlOcppKey, supervisionUrl.href) } else if (this.stationInfo != null) { @@ -3306,8 +3302,7 @@ export class ChargingStation extends EventEmitter { }) } if ( - this.stationInfo?.supervisionUrlOcppConfiguration === true && - isNotEmptyString(this.stationInfo.supervisionUrlOcppKey) && + isSupervisionOcppKeyActive(this) && getConfigurationKey(this, this.stationInfo.supervisionUrlOcppKey) == null ) { addConfigurationKey( diff --git a/src/charging-station/ConfigurationKeyUtils.ts b/src/charging-station/ConfigurationKeyUtils.ts index 000561c6..3ef21b2d 100644 --- a/src/charging-station/ConfigurationKeyUtils.ts +++ b/src/charging-station/ConfigurationKeyUtils.ts @@ -1,13 +1,14 @@ import type { ChargingStation } from './ChargingStation.js' import { + type ChargingStationInfo, type ConfigurationKey, type ConfigurationKeyType, OCPP20ComponentName, StandardParametersKey, VendorParametersKey, } from '../types/index.js' -import { isOCPP20x, logger } from '../utils/index.js' +import { isNotEmptyString, isOCPP20x, logger, SupervisionUrlSchema } from '../utils/index.js' export const buildConfigKey = (component: string, variable: string, instance?: string): string => { const base = `${component}.${variable}` @@ -116,16 +117,53 @@ const OCPP2_PARAMETER_KEY_MAP = new Map + isOCPP20x(ocppVersion) ? (OCPP2_PARAMETER_KEY_MAP.get(key) ?? key) : key + const resolveKey = ( chargingStation: ChargingStation, key: ConfigurationKeyType -): ConfigurationKeyType => { - const version = chargingStation.stationInfo?.ocppVersion - if (isOCPP20x(version)) { - return OCPP2_PARAMETER_KEY_MAP.get(key) ?? key - } - return key -} +): ConfigurationKeyType => resolveOcppParameterKey(chargingStation.stationInfo?.ocppVersion, key) + +/** + * Whether the station's supervision OCPP key is active: the configuration + * flag is on and the key is non-empty. Empty keys are never dialed — the + * consumer falls back to the configured supervision URL — so writers treat + * them as inactive instead of guarding a value the station never reads. + * @param chargingStation - Station whose stationInfo holds the flag and key. + * @returns `true` when the supervision OCPP key is active. + */ +export const isSupervisionOcppKeyActive = ( + chargingStation: ChargingStation +): chargingStation is ChargingStation & { + stationInfo: ChargingStationInfo & { supervisionUrlOcppKey: string } +} => + chargingStation.stationInfo?.supervisionUrlOcppConfiguration === true && + isNotEmptyString(chargingStation.stationInfo.supervisionUrlOcppKey) + +/** + * Whether a raw OCPP configuration value is acceptable as the supervision + * URL held by the active supervision OCPP key. The endpoint policy (schemes, fragment and query + * delimiters, `ws+unix:` socket and request path) is the shared + * `SupervisionUrlSchema`. The OCPP key stores the value as sent, so the raw + * value is checked for whitespace and control characters before schema + * parsing: `z.url()` strips surrounding whitespace from its output, which + * would hide padded values from the policy. The check is deliberately + * stricter than the dial path (which trims the base before appending the + * station identity): it enforces the same input hygiene as the runtime write + * guards and keeps the stored OCPP value clean. + * @param value - Candidate supervision URL. + * @returns `true` when the raw value has no whitespace or control characters + * and satisfies `SupervisionUrlSchema`. + */ +export const isSupportedSupervisionUrl = (value: string): boolean => + // eslint-disable-next-line no-control-regex -- control characters are rejected on purpose + !/[\s\u0000-\u001F\u007F]/.test(value) && SupervisionUrlSchema.safeParse(value).success export const warnOnOCPP16TemplateKeys = (chargingStation: ChargingStation): void => { const version = chargingStation.stationInfo?.ocppVersion diff --git a/src/charging-station/TemplateMigrations.ts b/src/charging-station/TemplateMigrations.ts index e5857f19..3e4f7e9b 100644 --- a/src/charging-station/TemplateMigrations.ts +++ b/src/charging-station/TemplateMigrations.ts @@ -8,7 +8,7 @@ const moduleName = 'TemplateMigrations' * Bump only on breaking changes (field rename, removal, type narrowing). * Single authoritative location — concurrent bumps force git merge conflict. */ -export const CURRENT_SCHEMA_VERSION = 1 +export const CURRENT_SCHEMA_VERSION = 2 type MigrationFn = (template: Record) => Record @@ -17,7 +17,7 @@ type MigrationFn = (template: Record) => Record): Record): Record { + return template +} diff --git a/src/charging-station/TemplateSchema.ts b/src/charging-station/TemplateSchema.ts index 6a87eaca..e2140c43 100644 --- a/src/charging-station/TemplateSchema.ts +++ b/src/charging-station/TemplateSchema.ts @@ -1,7 +1,10 @@ -import { z } from 'zod' +import { z, type ZodError } from 'zod' // Direct path: the `utils/index.js` barrel re-exports ConfigurationSchema.ts which uses Zod enums, causing a TDZ cycle. -import { convertToIntOrNaN } from '../utils/Utils.js' +import { SupervisionUrlSchema } from '../utils/ConfigurationSchema.js' +import { Constants } from '../utils/Constants.js' +import { convertToIntOrNaN, isNotEmptyString } from '../utils/Utils.js' +import { isSupportedSupervisionUrl, resolveOcppParameterKey } from './ConfigurationKeyUtils.js' import { CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js' // --------------------------------------------------------------- @@ -228,7 +231,9 @@ const BaseTemplateSchema = z.looseObject({ supervisionPassword: z.string().optional(), supervisionUrlOcppConfiguration: z.boolean().optional(), supervisionUrlOcppKey: z.string().optional(), - supervisionUrls: z.union([z.string(), z.array(z.string())]).optional(), + // Shared supervision endpoint policy: a value the transport cannot dial + // is rejected at template load instead of at connection time. + supervisionUrls: z.union([SupervisionUrlSchema, z.array(SupervisionUrlSchema)]).optional(), supervisionUser: z.string().optional(), templateHash: z.string().optional(), transactionDataMeterValues: z.boolean().optional(), @@ -259,6 +264,49 @@ export const TemplateSchema = BaseTemplateSchema.superRefine((template, ctx) => }) } } + // The seeded value of the active supervision OCPP key is dialed as the + // effective endpoint, so it must satisfy the shared supervision endpoint + // policy at template load; empty values are ignored by the consumer. + // The consumer merges the template with the station defaults before + // activating the key, so an omitted key falls back to the default + // connection key and the check resolves the same effective key. + const rawSupervisionKey = + template.supervisionUrlOcppKey ?? Constants.DEFAULT_STATION_INFO.supervisionUrlOcppKey + if (template.supervisionUrlOcppConfiguration === true && isNotEmptyString(rawSupervisionKey)) { + // The consumer resolves the key before matching configuration entries + // (resolveOcppParameterKey), so the check must match the same way. + const supervisionKey = resolveOcppParameterKey(template.ocppVersion, rawSupervisionKey) + // The consumer dials the first matching entry of the effective + // configuration (persisted station file when present, else this template; + // later duplicates are ignored, and an empty first value falls back to + // the configured supervision URL), so the template's first matching entry + // is subject to the check. + const configurationKeys = template.Configuration?.configurationKey ?? [] + const firstIndex = configurationKeys.findIndex(entry => entry.key === supervisionKey) + const entry = firstIndex === -1 ? undefined : configurationKeys[firstIndex] + // The OCPP key stores the value as sent, so the shared raw-value guard + // applies before schema parsing; it matches the runtime write guards + // (ChangeConfiguration, SetVariables). + if (entry != null && isNotEmptyString(entry.value) && !isSupportedSupervisionUrl(entry.value)) { + const result = SupervisionUrlSchema.safeParse(entry.value) + if (result.success) { + ctx.addIssue({ + code: 'custom', + message: `Seeded value of OCPP key '${supervisionKey}' must not contain whitespace or control characters`, + path: ['Configuration', 'configurationKey', firstIndex, 'value'], + }) + } else { + const error: ZodError = result.error + for (const issue of error.issues) { + ctx.addIssue({ + code: 'custom', + message: `Seeded value of OCPP key '${supervisionKey}' ${issue.message}`, + path: ['Configuration', 'configurationKey', firstIndex, 'value'], + }) + } + } + } + } const hasConnectors = template.Connectors != null const hasEvses = template.Evses != null if (hasConnectors && hasEvses) { diff --git a/src/charging-station/index.ts b/src/charging-station/index.ts index b56159e1..3dfffdb2 100644 --- a/src/charging-station/index.ts +++ b/src/charging-station/index.ts @@ -13,6 +13,9 @@ export { buildConfigKey, deleteConfigurationKey, getConfigurationKey, + isSupervisionOcppKeyActive, + isSupportedSupervisionUrl, + resolveOcppParameterKey, setConfigurationKeyValue, } from './ConfigurationKeyUtils.js' export { diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index cf4e7d68..29a6a345 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -23,6 +23,8 @@ import { getConfigurationKey, getConnectorChargingProfiles, getIdTagsFile, + isSupervisionOcppKeyActive, + isSupportedSupervisionUrl, prepareChargingProfileKind, removeExpiredReservations, resetAuthorizeConnectorStatus, @@ -133,7 +135,6 @@ import { isValidRandomIntBounds, logger, sleep, - SupervisionUrlSchema, truncateId, } from '../../../utils/index.js' import { AuthContext, type DifferentialAuthEntry, OCPPAuthServiceFactory } from '../auth/index.js' @@ -159,21 +160,6 @@ import { OCPP16ServiceUtils } from './OCPP16ServiceUtils.js' const moduleName = 'OCPP16IncomingRequestService' -/** - * Whether a value is a URL the supervision WebSocket client can dial once the - * charging station identity is appended to it. The endpoint policy (schemes, - * fragment and query delimiters, `ws+unix:` socket and request path) is the - * shared `SupervisionUrlSchema`. The OCPP key stores the value as sent, so - * whitespace and control characters are rejected here first: `z.url()` strips - * them from its output, but the raw value would still be dialed with them. - * @param value - Candidate supervision URL. - * @returns `true` when the raw value has no whitespace or control characters - * and satisfies `SupervisionUrlSchema`. - */ -const isSupportedSupervisionUrl = (value: string): boolean => - // eslint-disable-next-line no-control-regex -- control characters are rejected on purpose - !/[\s\u0000-\u001F\u007F]/.test(value) && SupervisionUrlSchema.safeParse(value).success - const requireOCPP16ChargePointStatus = (status: string | undefined): OCPP16ChargePointStatus => { const ocpp16Status = getEnumStringValue(OCPP16ChargePointStatus, status) if (ocpp16Status == null) { @@ -1202,7 +1188,7 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService !URL.canParse(value) || !new URL(value).href.includes('#'), { diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index a8ca2e8d..55dedac9 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -117,7 +117,7 @@ export const isEmpty = (value: unknown): boolean => { return false } -export const isOCPP20x = (version: OCPPVersion | undefined): boolean => +export const isOCPP20x = (version: string | undefined): boolean => version === OCPPVersion.VERSION_20 || version === OCPPVersion.VERSION_201 // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters diff --git a/tests/charging-station/ChargingStation-SupervisionUrl.test.ts b/tests/charging-station/ChargingStation-SupervisionUrl.test.ts index 74eb6322..09cd721e 100644 --- a/tests/charging-station/ChargingStation-SupervisionUrl.test.ts +++ b/tests/charging-station/ChargingStation-SupervisionUrl.test.ts @@ -11,6 +11,7 @@ import { Worker } from 'node:worker_threads' import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' +import { TemplateValidationError } from '../../src/charging-station/TemplateValidation.js' import { BaseError } from '../../src/exception/BaseError.js' import { type ChargingStationConfiguration, @@ -32,6 +33,9 @@ import { // retain it to verify diagnostics name the configured key, not the default. const OCPP_SUPERVISION_URL_KEY = 'ocppcentraladdress' const MALFORMED_SUPERVISION_URL = 'not a url' +// Syntactically valid per `new URL()`, unsupported by the supervision +// transport. +const UNSUPPORTED_SCHEME_URL = 'ftp://localhost/OCPP16' const stationInternalsOf = ( station: ChargingStation @@ -71,22 +75,22 @@ await describe('ChargingStation supervision URL validation', async () => { cleanupStationTemplates() }) - await it('should reject a malformed supervision URL with a field-naming configuration error', () => { - // A template with a relative or malformed supervision URL must surface a - // structured configuration error, never the bare `TypeError` from `new URL()`. - for (const malformedUrl of ['/relative', MALFORMED_SUPERVISION_URL]) { + await it('should reject a template supervision URL the transport cannot dial with a field-naming validation error', () => { + // A template whose supervision URL the supervision transport cannot dial + // (relative, malformed, or unsupported scheme) must fail template + // validation, naming the field, before station construction proceeds. + for (const invalidUrl of ['/relative', MALFORMED_SUPERVISION_URL, UNSUPPORTED_SCHEME_URL]) { assert.throws( () => - createStationFromTemplate(copyStationTemplate({ supervisionUrls: malformedUrl }), { + createStationFromTemplate(copyStationTemplate({ supervisionUrls: invalidUrl }), { baseName: TEST_CHARGING_STATION_BASE_NAME, fixedName: true, persistentConfiguration: false, - supervisionUrls: malformedUrl, + supervisionUrls: invalidUrl, }), - { - message: `${TEST_CHARGING_STATION_BASE_NAME}: Invalid supervision url '${malformedUrl}' configured in 'supervisionUrls'`, - name: 'BaseError', - } + (error: unknown) => + error instanceof TemplateValidationError && error.message.includes('supervisionUrls'), + `expected TemplateValidationError naming supervisionUrls for '${invalidUrl}'` ) } }) @@ -144,34 +148,121 @@ await describe('ChargingStation supervision URL validation', async () => { assert.strictEqual(station.wsConnectionUrl.href, initialWsConnectionUrl) }) - await it('should reject a malformed supervision URL seeded in the OCPP configuration key', () => { - // The OCPP configuration key can carry the supervision URL without going - // through setSupervisionUrl (template Configuration, or an OCPP - // ChangeConfiguration). The wsConnectionUrl getter is the only place that - // parses it, so it must raise the structured error too. + await it('should reject a value the transport cannot dial seeded in the OCPP configuration key at template load', () => { + // The seeded value of the active supervision OCPP key is dialed as the + // effective endpoint, so it is validated at template load, naming the key. + for (const value of [MALFORMED_SUPERVISION_URL, UNSUPPORTED_SCHEME_URL]) { + assert.throws( + () => + createStationFromTemplate( + copyStationTemplate({ + Configuration: { + configurationKey: [ + { + key: OCPP_SUPERVISION_URL_KEY, + readonly: false, + value, + }, + ], + }, + supervisionUrlOcppConfiguration: true, + supervisionUrlOcppKey: OCPP_SUPERVISION_URL_KEY, + }), + { + baseName: TEST_CHARGING_STATION_BASE_NAME, + fixedName: true, + persistentConfiguration: false, + } + ), + (error: unknown) => + error instanceof TemplateValidationError && + error.message.includes(OCPP_SUPERVISION_URL_KEY) && + error.filePath.includes('virtual-simple.station-template.json'), + `expected TemplateValidationError naming the OCPP key for '${value}'` + ) + } + }) + + await it('should reject a malformed supervision URL seeded in the OCPP configuration key outside template load', () => { + // A station built from a valid template (supervision flags active, no + // seeded entry, so template load passes) can still carry a malformed + // value in the OCPP key from a persisted station file or a manual edit, + // bypassing the template-load check. The wsConnectionUrl getter is the + // fail-closed dial-time parse and must raise the structured error naming the key. const station = createStationFromTemplate( copyStationTemplate({ - Configuration: { - configurationKey: [ - { - key: OCPP_SUPERVISION_URL_KEY, - readonly: false, - value: MALFORMED_SUPERVISION_URL, - }, - ], - }, supervisionUrlOcppConfiguration: true, supervisionUrlOcppKey: OCPP_SUPERVISION_URL_KEY, }), { baseName: TEST_CHARGING_STATION_BASE_NAME, fixedName: true, persistentConfiguration: false } ) + // The constructor seeds the active supervision OCPP key with the + // configured supervision URL; overwrite that entry's value to simulate a + // persisted station file (or a manual edit) carrying a malformed value. + const configurationKey = station.ocppConfiguration?.configurationKey + assert.ok( + configurationKey, + 'Expected the station to carry the template OCPP configuration keys' + ) + const supervisionEntry = configurationKey.find(entry => entry.key === OCPP_SUPERVISION_URL_KEY) + assert.ok(supervisionEntry, `Expected the seeded '${OCPP_SUPERVISION_URL_KEY}' entry`) + supervisionEntry.value = MALFORMED_SUPERVISION_URL + assert.throws(() => station.wsConnectionUrl, { message: `${TEST_CHARGING_STATION_BASE_NAME}: Invalid supervision url '${MALFORMED_SUPERVISION_URL}' configured in '${OCPP_SUPERVISION_URL_KEY}'`, name: 'BaseError', }) }) + await it('should reject an unsupported-scheme URL from setSupervisionUrl and preserve the previous endpoint on both branches', () => { + // Both setter branches must apply the shared supervision endpoint policy: a rejected + // update leaves the previous endpoint and credentials untouched. + const plain = createStationFromTemplate(copyStationTemplate(), { + baseName: TEST_CHARGING_STATION_BASE_NAME, + fixedName: true, + persistentConfiguration: false, + }) + const initialPlainUrl = plain.wsConnectionUrl.href + const initialPlainSupervisionUrls = plain.stationInfo?.supervisionUrls + + assert.throws( + () => { + plain.setSupervisionUrl(UNSUPPORTED_SCHEME_URL, 'keeper-user', 'keeper-password') + }, + { + message: `${TEST_CHARGING_STATION_BASE_NAME}: Invalid supervision url '${UNSUPPORTED_SCHEME_URL}' configured in 'supervisionUrls'`, + name: 'BaseError', + } + ) + assert.strictEqual(plain.wsConnectionUrl.href, initialPlainUrl) + assert.strictEqual(plain.stationInfo?.supervisionUrls, initialPlainSupervisionUrls) + // A rejected update must not store credentials either. + assert.strictEqual(plain.stationInfo?.supervisionUser, undefined) + assert.strictEqual(plain.stationInfo?.supervisionPassword, undefined) + + const ocpp = createStationFromTemplate( + copyStationTemplate({ + supervisionUrlOcppConfiguration: true, + supervisionUrlOcppKey: OCPP_SUPERVISION_URL_KEY, + }), + { baseName: TEST_CHARGING_STATION_BASE_NAME, fixedName: true, persistentConfiguration: false } + ) + const initialOcppUrl = ocpp.wsConnectionUrl.href + + assert.throws( + () => { + ocpp.setSupervisionUrl(UNSUPPORTED_SCHEME_URL) + }, + { + message: `${TEST_CHARGING_STATION_BASE_NAME}: Invalid supervision url '${UNSUPPORTED_SCHEME_URL}' configured in '${OCPP_SUPERVISION_URL_KEY}'`, + name: 'BaseError', + } + ) + // The rejected value must not have reached the OCPP configuration key. + assert.strictEqual(ocpp.wsConnectionUrl.href, initialOcppUrl) + }) + await it('should normalize supervision URLs in a worker and preserve persisted state on rejection', async () => { // Arrange: a real worker exercises the updated event and both storage branches. const url = 'wss://EXAMPLE.org:443 ' diff --git a/tests/charging-station/ConfigurationKeyUtils.test.ts b/tests/charging-station/ConfigurationKeyUtils.test.ts index 8477b3d9..d629eab8 100644 --- a/tests/charging-station/ConfigurationKeyUtils.test.ts +++ b/tests/charging-station/ConfigurationKeyUtils.test.ts @@ -10,6 +10,7 @@ import { buildConfigKey, deleteConfigurationKey, getConfigurationKey, + isSupportedSupervisionUrl, setConfigurationKeyValue, } from '../../src/charging-station/index.js' import { OCPP20ComponentName, OCPPVersion, StandardParametersKey } from '../../src/types/index.js' @@ -642,4 +643,21 @@ await describe('ConfigurationKeyUtils', async () => { assert.strictEqual(getConfigurationKey(cs, TEST_KEY_1), undefined) }) }) + + await describe('IsSupportedSupervisionUrl', async () => { + await it('should accept dialable ws and wss endpoints', () => { + assert.strictEqual(isSupportedSupervisionUrl('ws://localhost:8010'), true) + assert.strictEqual(isSupportedSupervisionUrl('wss://csms.example.com/ocpp'), true) + }) + + await it('should reject non-URL values', () => { + assert.strictEqual(isSupportedSupervisionUrl('30'), false) + assert.strictEqual(isSupportedSupervisionUrl('ftp://localhost/OCPP16'), false) + }) + + await it('should reject whitespace and control characters in the raw value', () => { + assert.strictEqual(isSupportedSupervisionUrl('ws://localhost:8010 '), false) + assert.strictEqual(isSupportedSupervisionUrl('ws://localhost:8010\x00'), false) + }) + }) }) diff --git a/tests/charging-station/TemplateMigrations.test.ts b/tests/charging-station/TemplateMigrations.test.ts index 81fada42..e4a9e773 100644 --- a/tests/charging-station/TemplateMigrations.test.ts +++ b/tests/charging-station/TemplateMigrations.test.ts @@ -24,7 +24,7 @@ await describe('TemplateMigrations', async () => { await describe('CURRENT_SCHEMA_VERSION', async () => { await it('should be a positive integer', () => { assert.ok(Number.isInteger(CURRENT_SCHEMA_VERSION)) - assert.strictEqual(CURRENT_SCHEMA_VERSION, 1) + assert.strictEqual(CURRENT_SCHEMA_VERSION, 2) }) }) @@ -139,6 +139,28 @@ await describe('TemplateMigrations', async () => { }) } + await it('should migrate v1 to v2 as an identity hop (narrowing is enforced by validation)', () => { + // The v2 supervisionUrls narrowing has no structural rewrite: a v1 + // template value the transport cannot dial is rejected by strict + // validation after the hop, never rewritten here. + const template = { + $schemaVersion: 1, + baseName: 'CS-TEST', + chargePointModel: 'Simulator simple', + chargePointVendor: 'Simulator', + supervisionUrls: ['ftp://localhost/OCPP16', TEST_SUPERVISION_URL], + } + + const result = applyMigration(1, template) + + assert.strictEqual(result, template) + assert.strictEqual(result.$schemaVersion, 2) + assert.deepStrictEqual(result.supervisionUrls, [ + 'ftp://localhost/OCPP16', + TEST_SUPERVISION_URL, + ]) + }) + await it('should set $schemaVersion to CURRENT_SCHEMA_VERSION after migration', t => { mockLoggerWarnDebug(t, logger) diff --git a/tests/charging-station/TemplateSchema.test.ts b/tests/charging-station/TemplateSchema.test.ts index 2bbbc7fe..f156b524 100644 --- a/tests/charging-station/TemplateSchema.test.ts +++ b/tests/charging-station/TemplateSchema.test.ts @@ -9,7 +9,10 @@ import { afterEach, describe, it } from 'node:test' import { CURRENT_SCHEMA_VERSION } from '../../src/charging-station/index.js' import { TemplateSchema } from '../../src/charging-station/index.js' import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' -import { TEST_SUPERVISION_URL } from '../utils/TestNetworkConstants.js' +import { + TEST_SUPERVISION_URL, + TEST_SUPERVISION_URL_ALT_PORT, +} from '../utils/TestNetworkConstants.js' import { TEST_CHARGING_STATION_BASE_NAME } from './ChargingStationTestConstants.js' import { buildMinimalTemplate } from './helpers/TemplateFixtures.js' @@ -216,6 +219,254 @@ await describe('TemplateSchema', async () => { }) }) + await describe('supervisionUrls (shared supervision endpoint policy)', async () => { + await it('should accept a single supported supervision URL (string form)', () => { + for (const url of [ + 'ws://localhost:8010', + 'wss://example.org/OCPP16', + 'http://localhost:8010/steve/websocket', + 'https://example.org/CentralSystemService', + 'ws+unix:///path/to/socket.sock:/OCPP16', + ]) { + const result = TemplateSchema.safeParse(buildMinimalTemplate({ supervisionUrls: url })) + assert.ok(result.success, `should accept ${url}`) + } + }) + + await it('should accept an array of supported supervision URLs', () => { + const urls = [TEST_SUPERVISION_URL, TEST_SUPERVISION_URL_ALT_PORT] + const result = TemplateSchema.safeParse(buildMinimalTemplate({ supervisionUrls: urls })) + assert.ok(result.success) + assert.deepStrictEqual(result.data.supervisionUrls, urls) + }) + + await it('should reject an unsupported scheme in string and array forms, naming the field', () => { + // The v2 narrowing: a syntactically valid URL the supervision transport + // cannot dial (e.g. `ftp://`) fails at template load, not at connection. + for (const url of ['ftp://localhost/OCPP16', 'file:///etc/passwd', 'gopher://localhost']) { + for (const value of [url, [url], [url, TEST_SUPERVISION_URL]]) { + const result = TemplateSchema.safeParse(buildMinimalTemplate({ supervisionUrls: value })) + assert.ok(!result.success, `should reject ${JSON.stringify(value)}`) + assert.ok( + result.error.issues.some( + issue => + issue.path.join('.').startsWith('supervisionUrls') && + issue.message.includes('must be a supervision URL') + ), + `rejection for ${JSON.stringify(value)} must name the supervisionUrls field` + ) + } + } + }) + + await it('should reject query and fragment delimiters (shared supervision endpoint policy)', () => { + for (const url of ['ws://localhost:8010?token=1', 'ws://localhost:8010#fragment']) { + const result = TemplateSchema.safeParse(buildMinimalTemplate({ supervisionUrls: url })) + assert.ok(!result.success, `should reject ${url}`) + assert.ok( + result.error.issues.some(issue => issue.path.join('.').startsWith('supervisionUrls')) + ) + } + }) + + await it('should reject an invalid ws+unix URL (missing request path separator)', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ supervisionUrls: 'ws+unix:///path/to/socket.sockOCPP16' }) + ) + assert.ok(!result.success) + assert.ok( + result.error.issues.some(issue => issue.path.join('.').startsWith('supervisionUrls')) + ) + }) + + await it('should trim surrounding Unicode whitespace from the parsed value', () => { + const padded = `\u00A0${TEST_SUPERVISION_URL}\u2003` + const result = TemplateSchema.safeParse(buildMinimalTemplate({ supervisionUrls: padded })) + assert.ok(result.success) + assert.strictEqual(result.data.supervisionUrls, TEST_SUPERVISION_URL) + }) + }) + + await describe('supervisionUrlOcppKey seeded configuration value', async () => { + const seededConfiguration = (value: string) => ({ + Configuration: { + configurationKey: [{ key: 'ocppcentraladdress', readonly: false, value }], + }, + supervisionUrlOcppConfiguration: true, + supervisionUrlOcppKey: 'ocppcentraladdress', + }) + + await it('should reject a value the transport cannot dial seeded in the active OCPP key, naming the key', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate(seededConfiguration('ftp://localhost/OCPP16')) + ) + assert.ok(!result.success) + assert.ok( + result.error.issues.some( + issue => + issue.path.join('.').startsWith('Configuration.configurationKey.0.value') && + issue.message.includes("'ocppcentraladdress'") + ), + `rejection must name the seeded OCPP key: ${JSON.stringify(result.error.issues)}` + ) + }) + + await it('should accept a supported value seeded in the active OCPP key', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate(seededConfiguration('ws://localhost:8010')) + ) + assert.ok(result.success) + }) + + await it('should ignore an empty value seeded in the active OCPP key', () => { + const result = TemplateSchema.safeParse(buildMinimalTemplate(seededConfiguration(''))) + assert.ok(result.success) + }) + + await it('should reject a padded value seeded in the active OCPP key, as the runtime write guards do', () => { + // The OCPP key stores the value as sent, so raw whitespace and control + // characters are rejected as the runtime write guards do, although + // SupervisionUrlSchema would normalize the surrounding ones. + const result = TemplateSchema.safeParse( + buildMinimalTemplate(seededConfiguration(' ws://localhost:8010 ')) + ) + assert.ok(!result.success) + assert.ok( + result.error.issues.some( + issue => + issue.path.join('.').startsWith('Configuration.configurationKey.0.value') && + issue.message.includes('must not contain whitespace or control characters') + ), + `rejection must name the whitespace rule: ${JSON.stringify(result.error.issues)}` + ) + }) + + await it('should reject a seeded value containing a control character', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate(seededConfiguration('ws://localhost:8010\u0000')) + ) + assert.ok(!result.success) + assert.ok( + result.error.issues.some(issue => + issue.path.join('.').startsWith('Configuration.configurationKey.0.value') + ), + `rejection must name the seeded value: ${JSON.stringify(result.error.issues)}` + ) + }) + + await it('should apply the check to the default key when the template omits supervisionUrlOcppKey', () => { + // The consumer merges the template with the station defaults before + // activating the key, so an omitted key resolves to the default + // ConnectionUrl and the seeded value for it is checked. + const omittedKey = (value: string) => ({ + Configuration: { + configurationKey: [{ key: 'ConnectionUrl', readonly: false, value }], + }, + supervisionUrlOcppConfiguration: true, + }) + + const rejected = TemplateSchema.safeParse( + buildMinimalTemplate(omittedKey('ftp://localhost/OCPP16')) + ) + assert.ok(!rejected.success) + assert.ok( + rejected.error.issues.some(issue => issue.message.includes("'ConnectionUrl'")), + `rejection must name the default OCPP key: ${JSON.stringify(rejected.error.issues)}` + ) + + const accepted = TemplateSchema.safeParse( + buildMinimalTemplate(omittedKey('ws://localhost:8010')) + ) + assert.ok(accepted.success) + }) + + await it('should ignore the seeded value when supervisionUrlOcppConfiguration is false', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ + ...seededConfiguration('ftp://localhost/OCPP16'), + supervisionUrlOcppConfiguration: false, + }) + ) + assert.ok(result.success) + }) + + await it('should reject an invalid value seeded under the resolved 2.0.x namespaced key spelling, naming it', () => { + // The consumer resolves the key before matching configuration entries + // (resolveOcppParameterKey), so the load check applies the same + // resolution and names the spelling present in the entry. + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ + ...seededConfiguration('ftp://localhost/OCPP16'), + Configuration: { + configurationKey: [ + { + key: 'OCPPCommCtrlr.HeartbeatInterval', + readonly: false, + value: 'ftp://localhost/OCPP16', + }, + ], + }, + ocppVersion: '2.0.1', + supervisionUrlOcppKey: 'HeartbeatInterval', + }) + ) + assert.ok(!result.success) + assert.ok( + result.error.issues.some(issue => issue.message.includes('OCPPCommCtrlr.HeartbeatInterval')) + ) + }) + + await it('should ignore a value seeded under the un-resolved key spelling on a 2.0.x station', () => { + // The consumer resolves the key before matching entries, so an entry + // whose spelling the resolution does not produce is never dialed and + // is not subject to the seeded-value check. + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ + ...seededConfiguration('ftp://localhost/OCPP16'), + Configuration: { + configurationKey: [ + { key: 'HeartbeatInterval', readonly: false, value: 'ftp://localhost/OCPP16' }, + ], + }, + ocppVersion: '2.0.1', + supervisionUrlOcppKey: 'HeartbeatInterval', + }) + ) + assert.ok(result.success) + }) + + await it('should validate only the first matching configuration key entry, mirroring the consumer', () => { + const duplicated = (firstValue: string, secondValue: string) => + buildMinimalTemplate({ + ...seededConfiguration('ftp://localhost/OCPP16'), + Configuration: { + configurationKey: [ + { key: 'ocppcentraladdress', readonly: false, value: firstValue }, + { key: 'ocppcentraladdress', readonly: false, value: secondValue }, + ], + }, + }) + + // A later invalid duplicate is inert: the consumer dials the first entry. + assert.ok( + TemplateSchema.safeParse(duplicated('ws://localhost:8010', 'ftp://localhost/OCPP16')) + .success + ) + // An empty first value falls back to the configured supervision URL; the later duplicate is inert. + assert.ok(TemplateSchema.safeParse(duplicated('', 'ftp://localhost/OCPP16')).success) + // An invalid first value is dialed and rejected at its own entry. + const rejected = TemplateSchema.safeParse( + duplicated('ftp://localhost/OCPP16', 'ws://localhost:8010') + ) + assert.ok(!rejected.success) + assert.ok( + rejected.error.issues.some( + issue => issue.path.join('.') === 'Configuration.configurationKey.0.value' + ) + ) + }) + }) + await describe('topology discrimination', async () => { await it('should reject template with both Connectors and Evses', () => { const result = TemplateSchema.safeParse( diff --git a/tests/charging-station/TemplateValidation.test.ts b/tests/charging-station/TemplateValidation.test.ts index 981a25a1..eba58769 100644 --- a/tests/charging-station/TemplateValidation.test.ts +++ b/tests/charging-station/TemplateValidation.test.ts @@ -12,7 +12,10 @@ import { TemplateValidationError, validateTemplate } from '../../src/charging-st import { BaseError } from '../../src/exception/index.js' import { clone, logger } from '../../src/utils/index.js' import { mockLoggerWarnDebug, standardCleanup } from '../helpers/TestLifecycleHelpers.js' -import { TEST_SUPERVISION_URL } from '../utils/TestNetworkConstants.js' +import { + TEST_SUPERVISION_URL, + TEST_SUPERVISION_URL_ALT_PORT, +} from '../utils/TestNetworkConstants.js' import { TEST_CHARGING_STATION_BASE_NAME } from './ChargingStationTestConstants.js' import { buildLegacyTemplate, buildMinimalTemplate } from './helpers/TemplateFixtures.js' @@ -31,10 +34,10 @@ await describe('TemplateValidation', async () => { assert.strictEqual(result.baseName, TEST_CHARGING_STATION_BASE_NAME) }) - await it('should accept string "$schemaVersion": "1" at current version (no-migration path)', t => { + await it('should accept string "$schemaVersion": "2" at current version (no-migration path)', t => { t.mock.method(logger, 'warn') const parsed = buildMinimalTemplate({ - $schemaVersion: '1', + $schemaVersion: '2', Connectors: { 0: {}, 1: {} }, }) @@ -168,9 +171,59 @@ await describe('TemplateValidation', async () => { } catch (error) { assert.ok(error instanceof TemplateValidationError) assert.strictEqual(error.migratedFrom, 0) - assert.match(error.message, /migrated from v0 → v1/) + assert.match(error.message, /migrated from v0 → v2/) } }) + + await it('should reject a v1 template carrying a supervision URL the transport cannot dial', t => { + // The v1→v2 hop is an identity: the unsupported value survives the + // migration and is rejected by the schema, naming the field. + mockLoggerWarnDebug(t, logger) + try { + validateTemplate( + buildMinimalTemplate({ + $schemaVersion: 1, + Connectors: { 0: {} }, + supervisionUrls: 'ftp://localhost/OCPP16', + }), + 'v1-unsupported-scheme.json' + ) + assert.fail('Expected TemplateValidationError') + } catch (error) { + assert.ok(error instanceof TemplateValidationError) + assert.strictEqual(error.migratedFrom, 1) + assert.strictEqual(error.filePath, 'v1-unsupported-scheme.json') + assert.ok( + error.fieldErrors.some( + fieldError => + fieldError.path.startsWith('supervisionUrls') && + fieldError.message.includes('must be a supervision URL') + ), + `rejection must name the supervisionUrls field: ${JSON.stringify(error.fieldErrors)}` + ) + } + }) + + await it('should migrate a v1 template with a supported supervision URL to the current version', t => { + mockLoggerWarnDebug(t, logger) + const result = validateTemplate( + buildMinimalTemplate({ + $schemaVersion: 1, + Connectors: { 0: {} }, + supervisionUrls: [TEST_SUPERVISION_URL, TEST_SUPERVISION_URL_ALT_PORT], + }), + 'v1-valid.json' + ) + + assert.strictEqual( + (result as unknown as Record).$schemaVersion, + CURRENT_SCHEMA_VERSION + ) + assert.deepStrictEqual(result.supervisionUrls, [ + TEST_SUPERVISION_URL, + TEST_SUPERVISION_URL_ALT_PORT, + ]) + }) }) await describe('transformTemplate', async () => { diff --git a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts index f08b19a2..05990bd8 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts @@ -322,6 +322,26 @@ await describe('OCPP16IncomingRequestService — Configuration', async () => { assert.strictEqual(getConfigurationKey(station, supervisionUrlOcppKey)?.value, value) } }) + + await it('should accept a non-dialable value when the supervision key is empty', () => { + // Arrange + const { station, testableService } = testContext + if (station.stationInfo != null) { + station.stationInfo.supervisionUrlOcppKey = '' + } + upsertConfigurationKey(station, '', supervisionUrl) + const request: ChangeConfigurationRequest = { + key: '', + value: 'ftp://localhost/OCPP16', + } + + // Act + const response = testableService.handleRequestChangeConfiguration(station, request) + + // Assert — an empty supervision key is never dialed, so the write is inert + assert.strictEqual(response.status, OCPP16ConfigurationStatus.ACCEPTED) + assert.strictEqual(getConfigurationKey(station, '')?.value, 'ftp://localhost/OCPP16') + }) }) // --------------------------------------------------------------------------- diff --git a/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts index 89311232..c73a4233 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts @@ -1531,6 +1531,160 @@ await describe('B05 - OCPP20VariableManager', async () => { }) }) + await describe('supervision URL dialability guard', async () => { + let guardStation: ChargingStation + let manager: OCPP20VariableManager + + beforeEach(() => { + const { station: newStation } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 3, + evseConfiguration: { evsesCount: 3 }, + ocppConfiguration: { + configurationKey: [ + { + key: buildConfigKey( + OCPP20ComponentName.OCPPCommCtrlr, + StandardParametersKey.HeartbeatInterval + ), + readonly: false, + value: '30', + }, + ], + }, + stationInfo: { + ocppVersion: OCPPVersion.VERSION_201, + supervisionUrlOcppConfiguration: true, + supervisionUrlOcppKey: StandardParametersKey.HeartbeatInterval, + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + guardStation = newStation + manager = OCPP20VariableManager.getInstance() + }) + + await it('should reject a value the transport cannot dial for the mapped supervision key variable', () => { + const result = manager.setVariables(guardStation, [ + { + attributeType: AttributeEnumType.Actual, + attributeValue: '30', + component: { name: OCPP20ComponentName.OCPPCommCtrlr }, + variable: { name: OCPP20OptionalVariableName.HeartbeatInterval }, + }, + ]) + + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0].attributeStatus, SetVariableStatusEnumType.Rejected) + if (result[0].attributeStatusInfo == null) { + assert.fail('Expected attributeStatusInfo to be defined') + } + assert.strictEqual(result[0].attributeStatusInfo.reasonCode, ReasonCodeEnumType.InvalidValue) + assert.strictEqual( + result[0].attributeStatusInfo.additionalInfo, + 'The supervision URL is not dialable' + ) + }) + + await it('should accept the same value when the supervision key is inactive', () => { + const { station: inactiveStation } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 3, + evseConfiguration: { evsesCount: 3 }, + ocppConfiguration: guardStation.ocppConfiguration, + stationInfo: { + ocppVersion: OCPPVersion.VERSION_201, + supervisionUrlOcppConfiguration: false, + supervisionUrlOcppKey: StandardParametersKey.HeartbeatInterval, + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + + const result = manager.setVariables(inactiveStation, [ + { + attributeType: AttributeEnumType.Actual, + attributeValue: '30', + component: { name: OCPP20ComponentName.OCPPCommCtrlr }, + variable: { name: OCPP20OptionalVariableName.HeartbeatInterval }, + }, + ]) + + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0].attributeStatus, SetVariableStatusEnumType.Accepted) + }) + + await it('should leave other variables settable when the supervision key is active', () => { + const result = manager.setVariables(guardStation, [ + { + attributeType: AttributeEnumType.Actual, + attributeValue: '30', + component: { name: OCPP20ComponentName.ChargingStation }, + variable: { name: OCPP20OptionalVariableName.WebSocketPingInterval }, + }, + ]) + + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0].attributeStatus, SetVariableStatusEnumType.Accepted) + }) + + await it('should be inert when the supervision key is empty', () => { + const { station: emptyKeyStation } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 3, + evseConfiguration: { evsesCount: 3 }, + ocppConfiguration: guardStation.ocppConfiguration, + stationInfo: { + ocppVersion: OCPPVersion.VERSION_201, + supervisionUrlOcppConfiguration: true, + supervisionUrlOcppKey: '', + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + + const result = manager.setVariables(emptyKeyStation, [ + { + attributeType: AttributeEnumType.Actual, + attributeValue: '30', + component: { name: OCPP20ComponentName.OCPPCommCtrlr }, + variable: { name: OCPP20OptionalVariableName.HeartbeatInterval }, + }, + ]) + + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0].attributeStatus, SetVariableStatusEnumType.Accepted) + }) + + await it('should be inert for an unmapped vendor key that never matches a component-scoped write key', () => { + const { station: vendorKeyStation } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 3, + evseConfiguration: { evsesCount: 3 }, + ocppConfiguration: { + configurationKey: [ + { key: 'ConnectionUrl', readonly: false, value: 'ws://localhost:8010' }, + ], + }, + stationInfo: { + ocppVersion: OCPPVersion.VERSION_201, + supervisionUrlOcppConfiguration: true, + supervisionUrlOcppKey: 'ConnectionUrl', + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + + const result = manager.setVariables(vendorKeyStation, [ + { + attributeType: AttributeEnumType.Actual, + attributeValue: 'ftp://localhost/OCPP16', + component: { name: OCPP20ComponentName.ChargingStation }, + variable: { name: OCPP20VendorVariableName.ConnectionUrl }, + }, + ]) + + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0].attributeStatus, SetVariableStatusEnumType.Accepted) + }) + }) + await describe('List validation tests', async () => { const manager = OCPP20VariableManager.getInstance() diff --git a/tests/charging-station/ui-server/ui-services/UIServiceRequestPayloadSchemas.test.ts b/tests/charging-station/ui-server/ui-services/UIServiceRequestPayloadSchemas.test.ts index 713ba136..25340a2a 100644 --- a/tests/charging-station/ui-server/ui-services/UIServiceRequestPayloadSchemas.test.ts +++ b/tests/charging-station/ui-server/ui-services/UIServiceRequestPayloadSchemas.test.ts @@ -263,7 +263,7 @@ await describe('UIServiceRequestPayloadSchemas', async () => { hashIds: [TEST_HASH_ID], url, }), - /url.*must be a WebSocket URL/ + /url.*must be a supervision URL/ ) } assert.strictEqual(service.getBroadcastChannelOutstandingResponseCount(TEST_UUID), 0)