]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(simulator): validate template supervision URLs with the shared endpoint policy...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 6 Nov 2026 15:52:33 +0000 (16:52 +0100)
committerGitHub <noreply@github.com>
Fri, 6 Nov 2026 15:52:33 +0000 (16:52 +0100)
* 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>
34 files changed:
README.md
cspell.config.yaml
src/assets/station-templates/abb-atg.station-template.json
src/assets/station-templates/abb.station-template.json
src/assets/station-templates/chargex.station-template.json
src/assets/station-templates/evlink.station-template.json
src/assets/station-templates/keba-ocpp2-signed.station-template.json
src/assets/station-templates/keba-ocpp2.station-template.json
src/assets/station-templates/keba.station-template.json
src/assets/station-templates/schneider-evses.station-template.json
src/assets/station-templates/schneider-imredd.station-template.json
src/assets/station-templates/schneider.station-template.json
src/assets/station-templates/siemens.station-template.json
src/assets/station-templates/virtual-simple-atg.station-template.json
src/assets/station-templates/virtual-simple-signed.station-template.json
src/assets/station-templates/virtual-simple.station-template.json
src/assets/station-templates/virtual.station-template.json
src/charging-station/ChargingStation.ts
src/charging-station/ConfigurationKeyUtils.ts
src/charging-station/TemplateMigrations.ts
src/charging-station/TemplateSchema.ts
src/charging-station/index.ts
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20VariableManager.ts
src/utils/ConfigurationSchema.ts
src/utils/Utils.ts
tests/charging-station/ChargingStation-SupervisionUrl.test.ts
tests/charging-station/ConfigurationKeyUtils.test.ts
tests/charging-station/TemplateMigrations.test.ts
tests/charging-station/TemplateSchema.test.ts
tests/charging-station/TemplateValidation.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts
tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts
tests/charging-station/ui-server/ui-services/UIServiceRequestPayloadSchemas.test.ts

index 0b228549ad2db4f3e48804204fb19788f68e127f..bd89478518f1ab091c22cefe6e86dab45b1dd2ed 100644 (file)
--- a/README.md
+++ b/README.md
@@ -206,11 +206,11 @@ The `<assets-dir>/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                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
index 7735b6b6903d9bfa9852d869c43e870cec1ea7e1..1046d38f6c9de5a105c6801b7d167b61fda822a0 100644 (file)
@@ -104,6 +104,8 @@ words:
   - emerg
   - REMAPPINGS
   - interruptible
+  - dialable
+  - dialability
   - HSTS
   - Hsts
   - hsts
index 835333ca189d328af7f32ed4ba497f90030aa9f1..ca2bebd2be03d923afc7fa542d8324c109e4c0ce 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "idTagsFile": "idtags.json",
   "baseName": "CS-ABB",
   "chargePointModel": "MD_TERRA_53",
index d8d1523b4ebaf856159866563a2132b32c4c174e..0afb774e6128e9d5fa8d0b68844006bc6128dff3 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "idTagsFile": "idtags.json",
   "baseName": "CS-ABB",
   "chargePointModel": "MD_TERRA_53",
index 95875f2172915b2c669d691ebd2cde4041129d7b..a3ec566723d1f337b01fdb0ee01c7aa884f49d11 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "idTagsFile": "idtags.json",
   "baseName": "CS-CHARGEX",
   "chargePointModel": "Aqueduct 1.0",
index 1e72929b71dcc8ee46dccf5c5b4510a2c083d328..e5387c4c32170ecf9a83d645681f6f952d89e67f 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "supervisionUrlOcppConfiguration": true,
   "supervisionUrlOcppKey": "ocppcentraladdress",
   "idTagsFile": "idtags.json",
index f9bed001e7c0f1142a16c126e4dac02d7a03c3b8..d219cf4ef420ad0af05c5cd484058e3577b0de35 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "supervisionUrls": ["ws://localhost:9000"],
   "supervisionUrlOcppConfiguration": true,
   "supervisionUrlOcppKey": "CentralSystemAddress",
index 71c3c9d40eafd41001d1a1ebfd5e61a79e23c732..47ee2084aa5598bf042b0ac6bf4bf05d9557f3c2 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "supervisionUrls": ["ws://localhost:9000"],
   "supervisionUrlOcppConfiguration": true,
   "supervisionUrlOcppKey": "CentralSystemAddress",
index a306612a88a045222f503a911e1edaaf5cd7e70b..a2335ef0fc17f26af349f7ee364e3dd687b4f64b 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "supervisionUrlOcppConfiguration": true,
   "supervisionUrlOcppKey": "CentralSystemAddress",
   "idTagsFile": "idtags.json",
index 9962b9220348d93658ff41d5fa031f5eba7abc15..323852516533895a257ae06af42f11d636c4a08f 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "supervisionUrlOcppConfiguration": true,
   "supervisionUrlOcppKey": "ocppcentraladdress",
   "idTagsFile": "idtags.json",
index 586183b83764045ffda39008db2efbcbd03f4f1d..b51f3c892eff5d703b8eb357a26551346d96fd13 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "supervisionUrlOcppConfiguration": true,
   "supervisionUrlOcppKey": "ocppcentraladdress",
   "idTagsFile": "idtags.json",
index 05983b9d9a12980bdc6d07321634542e03132666..3b07e0782ac6889ecb6d0a6083fb343224c9ba0b 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "supervisionUrlOcppConfiguration": true,
   "supervisionUrlOcppKey": "ocppcentraladdress",
   "idTagsFile": "idtags.json",
index ddd5a30a65714176ac872c470ebd29ad2ca4ed39..20eb6fc13f3bc23b3d8a441361ff9908eced7d74 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "idTagsFile": "idtags.json",
   "baseName": "CS-SIEMENS",
   "fixedName": true,
index 2b0017edeae0139e24c71323a78c0f6d2eb88680..29fd6d9d72e0ca8a3ff467aa90613ce05220656a 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "idTagsFile": "idtags.json",
   "baseName": "CS-BASIC",
   "chargePointModel": "Simulator simple",
index f04c99119baa04aa9caf78221aa7ab4f30d6a4a3..8b50c44ffe2ba2d2fcbe53868b872e79b755a91c 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "idTagsFile": "idtags.json",
   "baseName": "CS-BASIC-SIGNED",
   "chargePointModel": "Simulator simple",
index 74f1026565e6a2d5632a86d3dee3f7470644eeac..cafe1830eb04a83d0375c32f34b3bcdc7be07789 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "idTagsFile": "idtags.json",
   "baseName": "CS-BASIC",
   "chargePointModel": "Simulator simple",
index 27cd659e3029f676250e850a03f221e647077f1c..81d75fc189287db41ccbe94347fdc485b47e8c67 100644 (file)
@@ -1,5 +1,5 @@
 {
-  "$schemaVersion": 1,
+  "$schemaVersion": 2,
   "idTagsFile": "idtags.json",
   "baseName": "CS-SIMU",
   "chargePointModel": "Simulator connectors",
index a4ca743300977626e98987708f6bfe746df92437..5bd26b0d8b360d521b14324830017fbc9ab532a8 100644 (file)
@@ -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(
index 000561c6abd5523d2f911948c9c423310269ba71..3ef21b2debe055f5db69c0397d065af6b1ce2af2 100644 (file)
@@ -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<ConfigurationKeyType, ConfigurationKeyTy
   ],
 ])
 
+// OCPP 2.0.x stations store the mapped 1.6 keys under their 2.0.1
+// namespaced variable name; other versions use the key as-is.
+export const resolveOcppParameterKey = (
+  ocppVersion: string | undefined,
+  key: ConfigurationKeyType
+): ConfigurationKeyType =>
+  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
index e5857f1971a75803fcc7c2baf7ea7983da25a3d3..3e4f7e9b6829251a0a59db2c8db356d11a2dd12e 100644 (file)
@@ -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<string, unknown>) => Record<string, unknown>
 
@@ -17,7 +17,7 @@ type MigrationFn = (template: Record<string, unknown>) => Record<string, unknown
  * To add schema version N+1: append one `migrateV{N}ToV{N+1}` function and
  * bump `CURRENT_SCHEMA_VERSION`.
  */
-const migrationChain: readonly MigrationFn[] = [migrateV0ToV1]
+const migrationChain: readonly MigrationFn[] = [migrateV0ToV1, migrateV1ToV2]
 
 /**
  * Strict integer-string pattern for `$schemaVersion`. Rejects permissive
@@ -136,3 +136,13 @@ function migrateV0ToV1 (template: Record<string, unknown>): Record<string, unkno
   }
   return template
 }
+
+/**
+ * Migrate a v1 template to v2. Identity hop: the `supervisionUrls`
+ * narrowing is enforced by validation, not by a structural rewrite.
+ * @param template - Pre-migration template object
+ * @returns Same reference, unchanged
+ */
+function migrateV1ToV2 (template: Record<string, unknown>): Record<string, unknown> {
+  return template
+}
index 6a87eaca9d5b45f7b09510104f81a131767690d3..e2140c43059a355a4af601942f24ba9dadd97beb 100644 (file)
@@ -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) {
index b56159e176073d3a90cd60978a6e0479b99fec98..3dfffdb291c2ec3fba27d34348186739cafc6ea8 100644 (file)
@@ -13,6 +13,9 @@ export {
   buildConfigKey,
   deleteConfigurationKey,
   getConfigurationKey,
+  isSupervisionOcppKeyActive,
+  isSupportedSupervisionUrl,
+  resolveOcppParameterKey,
   setConfigurationKeyValue,
 } from './ConfigurationKeyUtils.js'
 export {
index cf4e7d68d1790126fb4fa78d6dc5d0b33b125366..29a6a3456a2f34d4c9802d3287e4c9c4f1e6e9ed 100644 (file)
@@ -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<OCP
       // The supervision URL key is dialed by the WebSocket client on reconnect;
       // reject a value it cannot dial instead of persisting it.
       if (
-        chargingStation.stationInfo?.supervisionUrlOcppConfiguration === true &&
+        isSupervisionOcppKeyActive(chargingStation) &&
         keyToChange.key === chargingStation.stationInfo.supervisionUrlOcppKey &&
         !isSupportedSupervisionUrl(value)
       ) {
index 012da23bc8c333ec97891ff649a07c78f8b73ea8..3b18ba8bc5b38a98880898ad42b5cd5e19998871 100644 (file)
@@ -29,6 +29,9 @@ import {
   buildConfigKey,
   type ChargingStation,
   getConfigurationKey,
+  isSupervisionOcppKeyActive,
+  isSupportedSupervisionUrl,
+  resolveOcppParameterKey,
   setConfigurationKeyValue,
 } from '../../index.js'
 import { OCPP20Constants } from './OCPP20Constants.js'
@@ -1101,6 +1104,32 @@ export class OCPP20VariableManager {
 
     let rebootRequired = false
     const configurationKeyName = computeScopedConfigurationKeyName(variableMetadata, component)
+    // A supervision OCPP key that is a settable registry variable can be
+    // pointed at an endpoint the transport cannot dial; enforce the same
+    // dialability guard as the OCPP 1.6 ChangeConfiguration handler. A key
+    // matches a component-scoped write key only when its resolved spelling
+    // is namespaced, so flat unmapped keys are inert here, as is the
+    // BootNotification heartbeat-interval write: a supervision key resolving
+    // to HeartbeatInterval is a contradictory configuration. The dial-time
+    // parse remains fail-closed for both.
+    if (
+      isSupervisionOcppKeyActive(chargingStation) &&
+      configurationKeyName ===
+        resolveOcppParameterKey(
+          chargingStation.stationInfo.ocppVersion,
+          chargingStation.stationInfo.supervisionUrlOcppKey
+        ) &&
+      !isSupportedSupervisionUrl(attributeValue)
+    ) {
+      return this.rejectSet(
+        variable,
+        component,
+        resolvedAttributeType,
+        SetVariableStatusEnumType.Rejected,
+        ReasonCodeEnumType.InvalidValue,
+        'The supervision URL is not dialable'
+      )
+    }
     const previousValue =
       this.getRuntimeOverrides(stationId).get(variableKey) ??
       getConfigurationKey(chargingStation, configurationKeyName)?.value
index 0df1b567bbd57aa4d19dcc4a888617cc8efad6db..8c51186ee6127ac15789d2a47d2839e5810bdc57 100644 (file)
@@ -328,7 +328,7 @@ export const StationTemplateUrlSchema = z
  */
 export const SupervisionUrlSchema = z
   .url({
-    error: 'must be a WebSocket URL (ws://, wss://, http://, https:// or ws+unix://)',
+    error: 'must be a supervision URL (ws://, wss://, http://, https:// or ws+unix://)',
     protocol: /^(?:wss?|https?|ws\+unix)$/,
   })
   .refine(value => !URL.canParse(value) || !new URL(value).href.includes('#'), {
index a8ca2e8dffbd71fa5049ffb297416471260b7062..55dedac9c2bffb1b29ae00346ddc63685e0be4e4 100644 (file)
@@ -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
index 74eb6322e35033fad1cdfd1568fa950f9294be7f..09cd721efaee456ff46171381060de2df9ca378c 100644 (file)
@@ -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 '
index 8477b3d9f102c40df7e908edbf69a9d25432b5a9..d629eab8321684e1820929e2c3a397ce70db5ab2 100644 (file)
@@ -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)
+    })
+  })
 })
index 81fada42d6ed2d28d1b744eb2e66aee1c7876fa0..e4a9e773af552873a8399799c781e84742ab8756 100644 (file)
@@ -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)
 
index 2bbbc7fe4ee1b91492a4ab6d16597ba0c5910e47..f156b5242a58eef74867d66c7edaf5c9f7f5e967 100644 (file)
@@ -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(
index 981a25a1559d9ebdecc3db2275840e8264a53c80..eba58769a488a2baeb06639016273ecfda38c3e2 100644 (file)
@@ -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<string, unknown>).$schemaVersion,
+        CURRENT_SCHEMA_VERSION
+      )
+      assert.deepStrictEqual(result.supervisionUrls, [
+        TEST_SUPERVISION_URL,
+        TEST_SUPERVISION_URL_ALT_PORT,
+      ])
+    })
   })
 
   await describe('transformTemplate', async () => {
index f08b19a25c9dda07f8e96c1f8d1f012f2b2f89da..05990bd81adedcb79179b3aea355d74675455db7 100644 (file)
@@ -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')
+    })
   })
 
   // ---------------------------------------------------------------------------
index 89311232bc8c127afde74c86d2a618ed0a2a582d..c73a423310d191252138419f84d95733f53b13ee 100644 (file)
@@ -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()
 
index 713ba136120941b13c7e279706b5b203b24fcbe6..25340a2a4dbf3d27f2bd0ab548a4c510bba05ff3 100644 (file)
@@ -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)