From 8bb806dc4ae42aafe100009dfd34be4809ebc921 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 00:38:50 +0200 Subject: [PATCH] =?utf8?q?feat:=20resolve=20#314=20=E2=80=94=20Add=20charg?= =?utf8?q?ing=20station=20template=20Zod=20validation=20with=20schema=20ve?= =?utf8?q?rsioning=20(#1860)?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * feat: add charging station template Zod validation with schema versioning Add Zod v4 runtime validation for charging station templates with schema versioning support. This replaces scattered imperative checks with a declarative schema pipeline that validates, migrates, and transforms templates at parse time. New files: - TemplateMigrations.ts: CURRENT_SCHEMA_VERSION, coerceVersion(), migration registry with migrateV0ToV1() - TemplateSchema.ts: Zod schemas with topology union, loose + strict variants, MeterValues value coercion (number -> string) - TemplateValidation.ts: validateTemplate() pipeline, transformTemplate(), TemplateValidationError Integration: - getTemplateFromFile() now calls validateTemplate() instead of bare JSON.parse(...) as ChargingStationTemplate - Cache key updated to hash:vN format incorporating schema version - Removed checkTemplate(), checkConnectorsConfiguration(), checkEvsesConfiguration(), warnTemplateKeysDeprecation() from Helpers.ts (logic absorbed into schema/pipeline) - Exported getConfiguredMaxNumberOfConnectors and getMaxNumberOfConnectors from Helpers.ts for connector setup Template changes: - Added "$schemaVersion": 1 to all 15 template JSON files Tests: - TemplateMigrations.test.ts: version coercion, migration, error cases - TemplateSchema.test.ts: required fields, topology, EVSE validation, MeterValues coercion, all 15 templates pass - TemplateValidation.test.ts: pipeline, transforms, error class, round-trip Closes #314 * fix(template): address PR #1860 review findings - coerceVersion(null|undefined) now returns 0 so legacy templates without $schemaVersion go through v0->v1 migration; deprecated keys (supervisionUrl, authorizationFile, payloadSchemaValidation, mustAuthorizeAtRemoteStart) are renamed instead of being silently swallowed by looseObject [HIGH] - transformTemplate uses Math.max(numberOfConnectors[]) as the worst-case bound for the randomConnectors auto-trigger; new Helpers.ts pair getMaxConfiguredNumberOfConnectors (deterministic upper bound) and pickConfiguredNumberOfConnectors (random pick) centralize array semantics; getConfiguredMaxNumberOfConnectors now delegates to pickConfiguredNumberOfConnectors [HIGH] - BaseTemplateSchema.$schemaVersion is now z.literal(CURRENT_SCHEMA_VERSION) (post-migration assertion); deprecated keys removed from the v1 schema and explicitly rejected by a superRefine block with a clear diagnostic [MEDIUM] - transformTemplate drops the unreachable templateMaxConnectors < 0 branch [LOW] - validateTemplate widens to accept unknown and rejects null, non-plain-object and array payloads with a clear BaseError instead of crashing later in coerceVersion [LOW] - SignedMeterValueSchema, UnitOfMeasureSchema and WsOptionsSchema replace z.looseObject({}) with typed shapes plus .catchall(z.unknown()) for forward-compatible vendor extensions [LOW] - TemplateValidationError now surfaces '(migrated from vX -> vY)' in its message so post-migration validation failures are visible to operators Tests: update coerceVersion(null|undefined) expectation to 0; add end-to-end legacy migration, deprecated-key rejection (Schema and Validation layers), null/string/array payload guards, array-form numberOfConnectors auto-trigger regression, dead-branch regression, migratedFrom message presence, and unit tests for the two new Helpers.ts helpers. * fix(template): address second-pass PR #1860 review findings - normalize $schemaVersion to coerced integer in validateTemplate so the no-migration path (when version === CURRENT) also satisfies the strict z.literal(CURRENT_SCHEMA_VERSION) check; was failing for user-authored templates with string "$schemaVersion": "1" [F1] - harmonize coerceVersion error wording on "non-negative integer" across all rejection branches; the previous "positive integer" message was contradictory since 0 is a valid input (legacy/pre-versioning sentinel triggering v0 migration) [F2] - introduce SCHEMA_VERSION_STRING_PATTERN (^\\d+$) gate in coerceVersion to reject permissive Number() coercions ('1.0', '0x1', '1e0', ' 1 ', '', '+1', '01a'); pattern mirrors the canonical non-negative-integer string pattern used in TemplateSchema for connector/EVSE keys [F6] - replace for...in with Object.entries destructuring in Evses topology validation, harmonizing with the prior-art pattern in Helpers.ts and removing the redundant template.Evses[evseKey] re-lookup [F3] - tighten getMaxConfiguredNumberOfConnectors cast to readonly number[] matching the helper signature [F5/F7] - document the cache-bound warning emission frequency in transformTemplate's JSDoc: warnings fire once per (templateFile, schemaVersion) cache miss rather than per station instance, which is template-scoped on purpose [F4] Tests: assert string "$schemaVersion": "1" is accepted and normalized to numeric 1; battery of 8 permissive numeric strings rejected with harmonized wording; coerceVersion rejection branches all use the same "non-negative integer" diagnostic. * fix(template): broaden wsOptions.headers and harden template pipeline - WsOptionsSchema.headers accepts string | number | string[] values, matching Node's OutgoingHttpHeader runtime contract; field-names enforced non-empty per RFC 9110 §5.1. - templateHash uses SRI-style `${algorithm}:${schemaVersion}:${contentHash}` computed over the validated template, restoring whitespace-insensitive cache semantics from pre-PR main while keeping schema-version-bump invalidation deterministic. - Migration registry refactored to sequential n→n+1 chain so future schema versions append one function instead of rewriting prior migrations (no behavior change at v1). - validateTemplate clones its input via structuredClone at the validation boundary, honoring the repository immutability convention. BREAKING CHANGE: external log monitors keyed on the literal string "Failed to read charging station template file" must also match "Invalid charging station template payload (not a JSON object)" for JSON-shape errors; file I/O errors retain their previous wording via handleFileException. * test: extract mockLoggerWarnDebug helper to dedupe migration logger spies Replaces 8 occurrences of the warn+debug t.mock.method pair across TemplateMigrations.test.ts and TemplateValidation.test.ts with a typed helper sibling to createLoggerMocks. * chore(deps): deduplicate pnpm-lock entries for @rolldown/pluginutils and ws --------- Co-authored-by: Coding Agent Co-authored-by: Jérôme Benoit Co-authored-by: Jérôme Benoit --- pnpm-lock.yaml | 30 +- .../abb-atg.station-template.json | 1 + .../abb.station-template.json | 1 + .../chargex.station-template.json | 1 + .../evlink.station-template.json | 1 + .../keba-ocpp2-signed.station-template.json | 1 + .../keba-ocpp2.station-template.json | 1 + .../keba.station-template.json | 1 + .../schneider-evses.station-template.json | 1 + .../schneider-imredd.station-template.json | 1 + .../schneider.station-template.json | 1 + .../siemens.station-template.json | 1 + .../virtual-simple-atg.station-template.json | 1 + ...irtual-simple-signed.station-template.json | 1 + .../virtual-simple.station-template.json | 1 + .../virtual.station-template.json | 1 + src/charging-station/ChargingStation.ts | 41 +-- src/charging-station/Helpers.ts | 210 +++--------- src/charging-station/TemplateMigrations.ts | 138 ++++++++ src/charging-station/TemplateSchema.ts | 302 ++++++++++++++++ src/charging-station/TemplateValidation.ts | 146 ++++++++ src/utils/Utils.ts | 2 +- tests/charging-station/Helpers.test.ts | 69 ++-- .../TemplateMigrations.test.ts | 149 ++++++++ tests/charging-station/TemplateSchema.test.ts | 324 ++++++++++++++++++ .../TemplateValidation.test.ts | 305 +++++++++++++++++ .../helpers/TemplateFixtures.ts | 39 +++ tests/helpers/TestLifecycleHelpers.ts | 15 + 28 files changed, 1542 insertions(+), 243 deletions(-) create mode 100644 src/charging-station/TemplateMigrations.ts create mode 100644 src/charging-station/TemplateSchema.ts create mode 100644 src/charging-station/TemplateValidation.ts create mode 100644 tests/charging-station/TemplateMigrations.test.ts create mode 100644 tests/charging-station/TemplateSchema.test.ts create mode 100644 tests/charging-station/TemplateValidation.test.ts create mode 100644 tests/charging-station/helpers/TemplateFixtures.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfb6a14f..38a92699 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1487,9 +1487,6 @@ packages: cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0': - resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==} - '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -4917,18 +4914,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.20.1: resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} @@ -5683,7 +5668,7 @@ snapshots: '@parcel/watcher': 2.5.6 effect: 3.21.2 multipasta: 0.2.7 - ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -5698,7 +5683,7 @@ snapshots: effect: 3.21.2 mime: 3.0.0 undici: 7.25.0 - ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -6241,8 +6226,6 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.0.1': optional: true - '@rolldown/pluginutils@1.0.0': {} - '@rolldown/pluginutils@1.0.1': {} '@simple-libs/child-process-utils@1.0.2': @@ -6465,7 +6448,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0 + '@rolldown/pluginutils': 1.0.1 '@vue/babel-plugin-jsx': 2.0.1(@babel/core@7.29.0) vite: 8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.22.1)(yaml@2.9.0) vue: 3.5.34(typescript@6.0.3) @@ -9227,7 +9210,7 @@ snapshots: rolldown@1.0.1: dependencies: '@oxc-project/types': 0.130.0 - '@rolldown/pluginutils': 1.0.0 + '@rolldown/pluginutils': 1.0.1 optionalDependencies: '@rolldown/binding-android-arm64': 1.0.1 '@rolldown/binding-darwin-arm64': 1.0.1 @@ -10070,11 +10053,6 @@ snapshots: wrappy@1.0.2: {} - ws@8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): - optionalDependencies: - bufferutil: 4.1.0 - utf-8-validate: 6.0.6 - ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6): optionalDependencies: bufferutil: 4.1.0 diff --git a/src/assets/station-templates/abb-atg.station-template.json b/src/assets/station-templates/abb-atg.station-template.json index b9c9aaa6..835333ca 100644 --- a/src/assets/station-templates/abb-atg.station-template.json +++ b/src/assets/station-templates/abb-atg.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "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 9a12fc32..d8d1523b 100644 --- a/src/assets/station-templates/abb.station-template.json +++ b/src/assets/station-templates/abb.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "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 b3122496..95875f21 100644 --- a/src/assets/station-templates/chargex.station-template.json +++ b/src/assets/station-templates/chargex.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "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 920d5cc1..1e72929b 100644 --- a/src/assets/station-templates/evlink.station-template.json +++ b/src/assets/station-templates/evlink.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "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 c19d9bab..f9bed001 100644 --- a/src/assets/station-templates/keba-ocpp2-signed.station-template.json +++ b/src/assets/station-templates/keba-ocpp2-signed.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "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 7ee304d3..71c3c9d4 100644 --- a/src/assets/station-templates/keba-ocpp2.station-template.json +++ b/src/assets/station-templates/keba-ocpp2.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "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 d51cc742..a306612a 100644 --- a/src/assets/station-templates/keba.station-template.json +++ b/src/assets/station-templates/keba.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "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 aceb747c..9962b922 100644 --- a/src/assets/station-templates/schneider-evses.station-template.json +++ b/src/assets/station-templates/schneider-evses.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "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 029fb214..586183b8 100644 --- a/src/assets/station-templates/schneider-imredd.station-template.json +++ b/src/assets/station-templates/schneider-imredd.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "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 5d3d798e..05983b9d 100644 --- a/src/assets/station-templates/schneider.station-template.json +++ b/src/assets/station-templates/schneider.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "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 5a51acb0..ddd5a30a 100644 --- a/src/assets/station-templates/siemens.station-template.json +++ b/src/assets/station-templates/siemens.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "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 8f527685..2b0017ed 100644 --- a/src/assets/station-templates/virtual-simple-atg.station-template.json +++ b/src/assets/station-templates/virtual-simple-atg.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "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 2b50af93..f04c9911 100644 --- a/src/assets/station-templates/virtual-simple-signed.station-template.json +++ b/src/assets/station-templates/virtual-simple-signed.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "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 c8a737dc..74f10265 100644 --- a/src/assets/station-templates/virtual-simple.station-template.json +++ b/src/assets/station-templates/virtual-simple.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "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 46f26238..27cd659e 100644 --- a/src/assets/station-templates/virtual.station-template.json +++ b/src/assets/station-templates/virtual.station-template.json @@ -1,4 +1,5 @@ { + "$schemaVersion": 1, "idTagsFile": "idtags.json", "baseName": "CS-SIMU", "chargePointModel": "Simulator connectors", diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index e9d86a7c..69e46b37 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -100,7 +100,6 @@ import { logPrefix, mergeDeepRight, min, - once, promiseWithTimeout, secureRandom, sleep, @@ -120,20 +119,19 @@ import { buildTemplateName, checkChargingStationState, checkConfiguration, - checkConnectorsConfiguration, - checkEvsesConfiguration, checkStationInfoConnectorStatus, - checkTemplate, createSerialNumber, getAmperageLimitationUnitDivider, getBootConnectorStatus, getChargingStationChargingProfilesLimit, getChargingStationId, + getConfiguredMaxNumberOfConnectors, getConnectorChargingProfilesLimit, getDefaultConnectorMaximumPower, getDefaultVoltageOut, getHashId, getIdTagsFile, + getMaxNumberOfConnectors, getMaxNumberOfEvses, getPhaseRotationValue, hasFeatureProfile, @@ -144,7 +142,6 @@ import { setChargingStationOptions, stationTemplateToStationInfo, validateStationInfo, - warnTemplateKeysDeprecation, } from './Helpers.js' import { IdTagsCache } from './IdTagsCache.js' import { @@ -160,6 +157,8 @@ import { stopRunningTransactions, } from './ocpp/index.js' import { SharedLRUCache } from './SharedLRUCache.js' +import { CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js' +import { validateTemplate } from './TemplateValidation.js' const moduleName = 'ChargingStation' @@ -1603,15 +1602,6 @@ export class ChargingStation extends EventEmitter { logger.error(`${this.logPrefix()} ${moduleName}.getStationInfoFromTemplate: ${errorMsg}`) throw new BaseError(errorMsg) } - checkTemplate(stationTemplate, this.logPrefix(), this.templateFile) - const warnTemplateKeysDeprecationOnce = once(warnTemplateKeysDeprecation) - warnTemplateKeysDeprecationOnce(stationTemplate, this.logPrefix(), this.templateFile) - if (stationTemplate.Connectors != null) { - checkConnectorsConfiguration(stationTemplate, this.logPrefix(), this.templateFile) - } - if (stationTemplate.Evses != null) { - checkEvsesConfiguration(stationTemplate, this.logPrefix(), this.templateFile) - } const stationInfo = stationTemplateToStationInfo(stationTemplate) stationInfo.templateIndex = this.index stationInfo.templateName = buildTemplateName(this.templateFile) @@ -1655,13 +1645,16 @@ export class ChargingStation extends EventEmitter { } else { const measureId = `${FileType.ChargingStationTemplate} read` const beginId = PerformanceStatistics.beginMeasure(measureId) - template = JSON.parse(readFileSync(this.templateFile, 'utf8')) as ChargingStationTemplate + const rawContent = readFileSync(this.templateFile, 'utf8') + const parsed = JSON.parse(rawContent) as Record + template = validateTemplate(parsed, this.templateFile) PerformanceStatistics.endMeasure(measureId, beginId) - template.templateHash = hash( - Constants.DEFAULT_HASH_ALGORITHM, - JSON.stringify(template), - 'hex' - ) + // SRI-style key `${algorithm}:${schemaVersion}:${contentHash}`. + // Hashing the validated template (not the raw file) keeps the key + // stable across cosmetic whitespace edits; the algorithm and version + // prefixes ensure cache entries are invalidated when either bumps. + const contentHash = hash(Constants.DEFAULT_HASH_ALGORITHM, JSON.stringify(template), 'hex') + template.templateHash = `${Constants.DEFAULT_HASH_ALGORITHM}:${CURRENT_SCHEMA_VERSION.toString()}:${contentHash}` this.sharedLRUCache.setChargingStationTemplate(template) this.templateFileHash = template.templateHash } @@ -1783,7 +1776,6 @@ export class ChargingStation extends EventEmitter { logger.error(`${this.logPrefix()} ${moduleName}.initialize: ${errorMsg}`) throw new BaseError(errorMsg) } - checkTemplate(stationTemplate, this.logPrefix(), this.templateFile) this.configurationFile = join( dirname(this.templateFile.replace('station-templates', 'configurations')), `${getHashId(this.index, stationTemplate)}.json` @@ -1867,8 +1859,11 @@ export class ChargingStation extends EventEmitter { ) } if (stationTemplate.Connectors != null) { - const { configuredMaxConnectors, templateMaxAvailableConnectors, templateMaxConnectors } = - checkConnectorsConfiguration(stationTemplate, this.logPrefix(), this.templateFile) + const configuredMaxConnectors = getConfiguredMaxNumberOfConnectors(stationTemplate) + const templateMaxConnectors = getMaxNumberOfConnectors(stationTemplate.Connectors) + const templateMaxAvailableConnectors = + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + stationTemplate.Connectors[0] != null ? templateMaxConnectors - 1 : templateMaxConnectors const connectorsConfigHash = hash( Constants.DEFAULT_HASH_ALGORITHM, `${JSON.stringify(stationTemplate.Connectors)}${configuredMaxConnectors.toString()}`, diff --git a/src/charging-station/Helpers.ts b/src/charging-station/Helpers.ts index b92f58ab..c98239ce 100644 --- a/src/charging-station/Helpers.ts +++ b/src/charging-station/Helpers.ts @@ -322,7 +322,7 @@ export const getDefaultConnectorMaximumPower = ( return staticCount > 0 ? maximumPower / staticCount : undefined } -const getMaxNumberOfConnectors = ( +export const getMaxNumberOfConnectors = ( connectors: Record | undefined ): number => { if (connectors == null) { @@ -351,28 +351,6 @@ export const getBootConnectorStatus = ( return ConnectorStatusEnum.Available } -export const checkTemplate = ( - stationTemplate: ChargingStationTemplate | undefined, - logPrefix: string, - templateFile: string -): void => { - if (stationTemplate == null) { - const errorMsg = `Failed to read charging station template file ${templateFile}` - logger.error(`${logPrefix} ${moduleName}.checkTemplate: ${errorMsg}`) - throw new BaseError(errorMsg) - } - if (isEmpty(stationTemplate)) { - const errorMsg = `Empty charging station information from template file ${templateFile}` - logger.error(`${logPrefix} ${moduleName}.checkTemplate: ${errorMsg}`) - throw new BaseError(errorMsg) - } - if (stationTemplate.idTagsFile == null || isEmpty(stationTemplate.idTagsFile)) { - logger.warn( - `${logPrefix} ${moduleName}.checkTemplate: Missing id tags file in template file ${templateFile}. That can lead to issues with the Automatic Transaction Generator` - ) - } -} - export const checkConfiguration = ( stationConfiguration: ChargingStationConfiguration | undefined, logPrefix: string, @@ -390,68 +368,6 @@ export const checkConfiguration = ( } } -export const checkConnectorsConfiguration = ( - stationTemplate: ChargingStationTemplate, - logPrefix: string, - templateFile: string -): { - configuredMaxConnectors: number - templateMaxAvailableConnectors: number - templateMaxConnectors: number -} => { - const configuredMaxConnectors = getConfiguredMaxNumberOfConnectors(stationTemplate) - checkConfiguredMaxConnectors(configuredMaxConnectors, logPrefix, templateFile) - const templateMaxConnectors = getMaxNumberOfConnectors(stationTemplate.Connectors) - checkTemplateMaxConnectors(templateMaxConnectors, logPrefix, templateFile) - const templateMaxAvailableConnectors = - stationTemplate.Connectors?.[0] != null ? templateMaxConnectors - 1 : templateMaxConnectors - if ( - configuredMaxConnectors > templateMaxAvailableConnectors && - stationTemplate.randomConnectors !== true - ) { - logger.warn( - `${logPrefix} ${moduleName}.checkConnectorsConfiguration: Number of connectors exceeds the number of connector configurations in template ${templateFile}, forcing random connector configurations affectation` - ) - stationTemplate.randomConnectors = true - } - return { - configuredMaxConnectors, - templateMaxAvailableConnectors, - templateMaxConnectors, - } -} - -export const checkEvsesConfiguration = ( - stationTemplate: ChargingStationTemplate, - logPrefix: string, - templateFile: string -): void => { - if (stationTemplate.Evses == null) { - return - } - for (const evseKey in stationTemplate.Evses) { - const evseId = convertToInt(evseKey) - const connectorIds = Object.keys(stationTemplate.Evses[evseKey].Connectors).map(convertToInt) - if (evseId === 0) { - for (const connectorId of connectorIds) { - if (connectorId !== 0) { - throw new BaseError( - `${logPrefix} Template ${templateFile} EVSE 0 has invalid connector id ${connectorId.toString()}, only connector id 0 is allowed (OCPP 2.0.1 §7.2)` - ) - } - } - } else if (evseId > 0) { - for (const connectorId of connectorIds) { - if (connectorId < 1) { - throw new BaseError( - `${logPrefix} Template ${templateFile} EVSE ${evseId.toString()} has invalid connector id ${connectorId.toString()}, connector ids must start at 1 (OCPP 2.0.1 §7.2)` - ) - } - } - } - } -} - export const checkStationInfoConnectorStatus = ( connectorId: number, connectorStatus: ConnectorStatus, @@ -636,29 +552,6 @@ export const prepareConnectorStatus = (connectorStatus: ConnectorStatus): Connec return connectorStatus } -export const warnTemplateKeysDeprecation = ( - stationTemplate: ChargingStationTemplate, - logPrefix: string, - templateFile: string -): void => { - const templateKeys: { deprecatedKey: string; key?: string }[] = [ - { deprecatedKey: 'supervisionUrl', key: 'supervisionUrls' }, - { deprecatedKey: 'authorizationFile', key: 'idTagsFile' }, - { deprecatedKey: 'payloadSchemaValidation', key: 'ocppStrictCompliance' }, - { deprecatedKey: 'mustAuthorizeAtRemoteStart', key: 'remoteAuthorization' }, - ] - for (const templateKey of templateKeys) { - warnDeprecatedTemplateKey( - stationTemplate, - templateKey.deprecatedKey, - logPrefix, - templateFile, - templateKey.key != null ? `Use '${templateKey.key}' instead` : undefined - ) - convertDeprecatedTemplateKey(stationTemplate, templateKey.deprecatedKey, templateKey.key) - } -} - export const stationTemplateToStationInfo = ( stationTemplate: ChargingStationTemplate ): ChargingStationInfo => { @@ -941,15 +834,15 @@ export const waitChargingStationEvents = async ( }) } -const getConfiguredMaxNumberOfConnectors = (stationTemplate: ChargingStationTemplate): number => { +export const getConfiguredMaxNumberOfConnectors = ( + stationTemplate: ChargingStationTemplate +): number => { + const picked = pickConfiguredNumberOfConnectors(stationTemplate.numberOfConnectors) + if (picked != null) { + return picked + } let configuredMaxNumberOfConnectors = 0 - if (isNotEmptyArray(stationTemplate.numberOfConnectors)) { - const numberOfConnectors = stationTemplate.numberOfConnectors - configuredMaxNumberOfConnectors = - numberOfConnectors[Math.floor(secureRandom() * numberOfConnectors.length)] - } else if (typeof stationTemplate.numberOfConnectors === 'number') { - configuredMaxNumberOfConnectors = stationTemplate.numberOfConnectors - } else if (stationTemplate.Connectors != null && stationTemplate.Evses == null) { + if (stationTemplate.Connectors != null && stationTemplate.Evses == null) { configuredMaxNumberOfConnectors = // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition stationTemplate.Connectors[0] != null @@ -964,32 +857,43 @@ const getConfiguredMaxNumberOfConnectors = (stationTemplate: ChargingStationTemp return configuredMaxNumberOfConnectors } -const checkConfiguredMaxConnectors = ( - configuredMaxConnectors: number, - logPrefix: string, - templateFile: string -): void => { - if (configuredMaxConnectors <= 0) { - logger.warn( - `${logPrefix} ${moduleName}.checkConfiguredMaxConnectors: Charging station information from template ${templateFile} with ${configuredMaxConnectors.toString()} connectors` - ) +/** + * Worst-case upper bound on the configured connector count from the + * `numberOfConnectors` template field. Used at validation time to decide + * whether `randomConnectors` must be auto-enabled (i.e. whether *any* + * runtime random pick could exceed the connector definitions). + * @param numberOfConnectors - Template `numberOfConnectors` field value + * @returns Upper bound, or `undefined` when the field is not set + */ +export const getMaxConfiguredNumberOfConnectors = ( + numberOfConnectors: number | readonly number[] | undefined +): number | undefined => { + if (isNotEmptyArray(numberOfConnectors)) { + return Math.max(...numberOfConnectors) + } + if (typeof numberOfConnectors === 'number') { + return numberOfConnectors } + return undefined } -const checkTemplateMaxConnectors = ( - templateMaxConnectors: number, - logPrefix: string, - templateFile: string -): void => { - if (templateMaxConnectors === 0) { - logger.warn( - `${logPrefix} ${moduleName}.checkTemplateMaxConnectors: Charging station information from template ${templateFile} with empty connectors configuration` - ) - } else if (templateMaxConnectors < 0) { - logger.error( - `${logPrefix} ${moduleName}.checkTemplateMaxConnectors: Charging station information from template ${templateFile} with no connectors configuration defined` - ) +/** + * Random pick from the `numberOfConnectors` template field. Used at + * runtime to materialize the actual connector count for one station + * instance. + * @param numberOfConnectors - Template `numberOfConnectors` field value + * @returns Picked count, or `undefined` when the field is not set + */ +export const pickConfiguredNumberOfConnectors = ( + numberOfConnectors: number | readonly number[] | undefined +): number | undefined => { + if (isNotEmptyArray(numberOfConnectors)) { + return numberOfConnectors[Math.floor(secureRandom() * numberOfConnectors.length)] } + if (typeof numberOfConnectors === 'number') { + return numberOfConnectors + } + return undefined } const initializeConnectorStatus = ( @@ -1009,36 +913,6 @@ const initializeConnectorStatus = ( } } -const warnDeprecatedTemplateKey = ( - template: ChargingStationTemplate, - key: string, - logPrefix: string, - templateFile: string, - logMsgToAppend = '' -): void => { - if (template[key as keyof ChargingStationTemplate] != null) { - const logMsg = `Deprecated template key '${key}' usage in file '${templateFile}'${ - isNotEmptyString(logMsgToAppend) ? `. ${logMsgToAppend}` : '' - }` - logger.warn(`${logPrefix} ${moduleName}.warnDeprecatedTemplateKey: ${logMsg}`) - } -} - -const convertDeprecatedTemplateKey = ( - template: ChargingStationTemplate, - deprecatedKey: string, - key?: string -): void => { - const templateRecord = template as unknown as Record - if (templateRecord[deprecatedKey] != null) { - if (key != null) { - templateRecord[key] = templateRecord[deprecatedKey] - } - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete templateRecord[deprecatedKey] - } -} - interface ChargingProfilesLimit { chargingProfile: ChargingProfile limit: number diff --git a/src/charging-station/TemplateMigrations.ts b/src/charging-station/TemplateMigrations.ts new file mode 100644 index 00000000..0654c04e --- /dev/null +++ b/src/charging-station/TemplateMigrations.ts @@ -0,0 +1,138 @@ +import { BaseError } from '../exception/index.js' +import { isNotEmptyString, logger } from '../utils/index.js' + +const moduleName = 'TemplateMigrations' + +/** + * Current schema version for charging station templates. + * 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 + +type MigrationFn = (template: Record) => Record + +/** + * Sequential migration chain. Index `i` migrates a v`i` template to v`i+1`. + * To add schema version N+1: append one `migrateV{N}ToV{N+1}` function and + * bump `CURRENT_SCHEMA_VERSION`. + */ +const migrationChain: readonly MigrationFn[] = [migrateV0ToV1] + +/** + * Strict integer-string pattern for `$schemaVersion`. Rejects permissive + * `Number()` coercions (`'1.0'`, `'0x1'`, `'1e0'`, `' 1 '`, `''`, `'+1'`). + */ +const SCHEMA_VERSION_STRING_PATTERN = /^\d+$/ + +/** + * Coerce a raw `$schemaVersion` value to a validated integer. + * - Missing → 0 (legacy/pre-versioning template — triggers v0→CURRENT migration + * so deprecated keys (`supervisionUrl`, `authorizationFile`, + * `payloadSchemaValidation`, `mustAuthorizeAtRemoteStart`) are renamed + * before strict schema validation) + * - Non-negative integer (number or canonical decimal string) → parsed integer + * - Anything else (negative, float, NaN, Infinity, hex/exponential/whitespace + * string, future version) → fatal error + * @param raw - Raw value from parsed JSON + * @returns Validated integer version + */ +export const coerceVersion = (raw: unknown): number => { + if (raw == null) { + return 0 + } + let parsed: number + let rawStr: string + if (typeof raw === 'number') { + parsed = raw + rawStr = String(raw) + } else if (typeof raw === 'string' && SCHEMA_VERSION_STRING_PATTERN.test(raw)) { + parsed = Number(raw) + rawStr = raw + } else { + if (typeof raw === 'object') { + rawStr = JSON.stringify(raw) + } else if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') { + rawStr = String(raw) + } else if (typeof raw === 'bigint') { + rawStr = `${raw.toString()}n` + } else if (typeof raw === 'symbol') { + rawStr = raw.toString() + } else { + rawStr = 'unknown' + } + throw new BaseError( + `${moduleName}.coerceVersion: Invalid $schemaVersion value '${rawStr}' — must be a non-negative integer` + ) + } + if (!Number.isInteger(parsed) || parsed < 0) { + throw new BaseError( + `${moduleName}.coerceVersion: Invalid $schemaVersion value '${rawStr}' — must be a non-negative integer` + ) + } + if (parsed > CURRENT_SCHEMA_VERSION) { + throw new BaseError( + `${moduleName}.coerceVersion: Template $schemaVersion ${parsed.toString()} is newer than supported version ${CURRENT_SCHEMA_VERSION.toString()}. Update the simulator to handle this template` + ) + } + return parsed +} + +/** + * Apply migrations sequentially from the given source version to + * `CURRENT_SCHEMA_VERSION`, advancing `$schemaVersion` after each hop. + * Mutates `template` in place and returns the same reference. Callers that + * need to preserve their input must clone before invocation. + * @param sourceVersion - Source schema version to migrate from + * @param template - Raw parsed template object + * @param filePath - Optional file path for log messages + * @returns Migrated template object + */ +export const applyMigration = ( + sourceVersion: number, + template: Record, + filePath?: string +): Record => { + if (sourceVersion < 0 || sourceVersion >= CURRENT_SCHEMA_VERSION) { + throw new BaseError( + `${moduleName}.applyMigration: No migration defined for $schemaVersion ${sourceVersion.toString()} → ${CURRENT_SCHEMA_VERSION.toString()}` + ) + } + logger.debug( + `${moduleName}.applyMigration: Migrating template${filePath != null ? ` '${filePath}'` : ''} from v${sourceVersion.toString()} to v${CURRENT_SCHEMA_VERSION.toString()}` + ) + let migrated = template + for (let v = sourceVersion; v < CURRENT_SCHEMA_VERSION; v++) { + migrated = migrationChain[v](migrated) + migrated.$schemaVersion = v + 1 + } + return migrated +} + +/** + * Migrate a v0 template to v1 by renaming deprecated keys to their v1 equivalents. + * @param template - Pre-migration template object + * @returns Same reference with deprecated keys renamed + */ +function migrateV0ToV1 (template: Record): Record { + const deprecatedKeys: { deprecatedKey: string; key?: string }[] = [ + { deprecatedKey: 'supervisionUrl', key: 'supervisionUrls' }, + { deprecatedKey: 'authorizationFile', key: 'idTagsFile' }, + { deprecatedKey: 'payloadSchemaValidation', key: 'ocppStrictCompliance' }, + { deprecatedKey: 'mustAuthorizeAtRemoteStart', key: 'remoteAuthorization' }, + ] + for (const { deprecatedKey, key } of deprecatedKeys) { + if (template[deprecatedKey] != null) { + const logMsg = `Deprecated template key '${deprecatedKey}' found${ + isNotEmptyString(key) ? `. Use '${key}' instead` : '' + }` + logger.warn(`${moduleName}.migrateV0ToV1: ${logMsg}`) + if (key != null) { + template[key] = template[deprecatedKey] + } + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete template[deprecatedKey] + } + } + return template +} diff --git a/src/charging-station/TemplateSchema.ts b/src/charging-station/TemplateSchema.ts new file mode 100644 index 00000000..ea85e50e --- /dev/null +++ b/src/charging-station/TemplateSchema.ts @@ -0,0 +1,302 @@ +import { z } from 'zod' + +import { CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js' + +// --------------------------------------------------------------- +// Sub-schemas +// --------------------------------------------------------------- + +/** + * SignedMeterValue — OCPP signed meter value envelope. + * `.catchall(z.unknown())` preserves forward-compatibility for + * vendor-specific extensions. + */ +const SignedMeterValueSchema = z + .object({ + encodingMethod: z.string().optional(), + publicKey: z.string().optional(), + signedMeterData: z.string().optional(), + signingMethod: z.string().optional(), + }) + .catchall(z.unknown()) + +/** + * UnitOfMeasure — OCPP 2.0 unit-of-measure descriptor. + */ +const UnitOfMeasureSchema = z + .object({ + multiplier: z.number().int().optional(), + unit: z.string().optional(), + }) + .catchall(z.unknown()) + +/** + * SampledValueTemplate — MeterValues entries in connectors. + * Accepts both string and number for `value` and normalizes to string, + * covering the type mismatch in templates like `evlink` (`"value": 0`). // cspell:ignore evlink + */ +const SampledValueTemplateSchema = z.looseObject({ + context: z.string().optional(), + fluctuationPercent: z.number().optional(), + format: z.string().optional(), + location: z.string().optional(), + measurand: z.string().optional(), + minimumValue: z.number().optional(), + phase: z.string().optional(), + signedMeterValue: SignedMeterValueSchema.optional(), + unit: z.string().optional(), + unitOfMeasure: UnitOfMeasureSchema.optional(), + value: z.union([z.string(), z.number()]).pipe(z.coerce.string()).optional(), +}) + +/** + * WsOptions — `ws.ClientOptions & ClientRequestArgs` intersection. + * The full surface is large (~60 fields) and external; the schema types the + * commonly used fields and preserves the rest via `.catchall(z.unknown())`. + */ +const WsOptionsSchema = z + .object({ + handshakeTimeout: z.number().optional(), + // Node's OutgoingHttpHeader at runtime accepts string | number | string[], + // broader than the strict ws.ClientOptions intersection. Field-names must + // be non-empty per RFC 9110 §5.1. + headers: z + .record(z.string().min(1), z.union([z.string(), z.number(), z.array(z.string())])) + .optional(), + maxPayload: z.number().optional(), + perMessageDeflate: z.union([z.boolean(), z.record(z.string(), z.unknown())]).optional(), + protocolVersion: z.number().optional(), + rejectUnauthorized: z.boolean().optional(), + skipUTF8Validation: z.boolean().optional(), + }) + .catchall(z.unknown()) + +/** + * ConnectorStatus — individual connector configuration within a template. + * Uses looseObject to tolerate runtime-only fields added by the simulator. + */ +const ConnectorStatusSchema = z.looseObject({ + bootStatus: z.string().optional(), + maximumPower: z.number().optional(), + MeterValues: z.array(SampledValueTemplateSchema).optional(), +}) + +/** + * EvseTemplate — EVSE entry containing its connectors. + */ +const EvseTemplateSchema = z.looseObject({ + Connectors: z.record(z.string().regex(/^\d+$/), ConnectorStatusSchema), + MeterValues: z.array(SampledValueTemplateSchema).optional(), +}) + +/** + * ConfigurationKey — OCPP configuration key entry. + * `key` is z.string() (open set: vendor-specific and OCPP 2.0 namespaced keys are valid). + */ +const ConfigurationKeySchema = z.looseObject({ + key: z.string(), + readonly: z.boolean(), + reboot: z.boolean().optional(), + value: z.string().optional(), + visible: z.boolean().optional(), +}) + +/** + * ChargingStationOcppConfiguration — the Configuration section. + */ +const OcppConfigurationSchema = z.looseObject({ + configurationKey: z.array(ConfigurationKeySchema).optional(), +}) + +/** + * AutomaticTransactionGeneratorConfiguration — ATG section. + * `stopAbsoluteDuration` is typed as required in the interface but absent from all templates, + * so it is optional in the schema (templates omit it; the runtime provides the default). + */ +const AutomaticTransactionGeneratorSchema = z.looseObject({ + enable: z.boolean(), + idTagDistribution: z.string().optional(), + maxDelayBetweenTwoTransactions: z.number(), + maxDuration: z.number(), + minDelayBetweenTwoTransactions: z.number(), + minDuration: z.number(), + probabilityOfStart: z.number(), + requireAuthorize: z.boolean().optional(), + stopAbsoluteDuration: z.boolean().optional(), + stopAfterHours: z.number(), +}) + +/** + * FirmwareUpgrade sub-schema. + */ +const FirmwareUpgradeSchema = z.looseObject({ + failureStatus: z.string().optional(), + reset: z.boolean().optional(), + versionUpgrade: z + .looseObject({ + patternGroup: z.number().optional(), + step: z.number().optional(), + }) + .optional(), +}) + +/** + * CommandsSupport sub-schema. + */ +const CommandsSupportSchema = z.looseObject({ + incomingCommands: z.record(z.string(), z.boolean()), + outgoingCommands: z.record(z.string(), z.boolean()).optional(), +}) + +// --------------------------------------------------------------- +// Connectors vs Evses topology variants +// --------------------------------------------------------------- + +const ConnectorsVariant = z.looseObject({ + Connectors: z.record(z.string().regex(/^\d+$/), ConnectorStatusSchema), +}) + +const EvsesVariant = z.looseObject({ + Evses: z.record(z.string().regex(/^\d+$/), EvseTemplateSchema), +}) + +// --------------------------------------------------------------- +// Main template schema (loose — tolerates unknown keys) +// --------------------------------------------------------------- + +const BaseTemplateSchema = z.looseObject({ + $schemaVersion: z.literal(CURRENT_SCHEMA_VERSION), + amperageLimitationOcppKey: z.string().optional(), + amperageLimitationUnit: z.string().optional(), + AutomaticTransactionGenerator: AutomaticTransactionGeneratorSchema.optional(), + automaticTransactionGeneratorPersistentConfiguration: z.boolean().optional(), + autoReconnectMaxRetries: z.number().optional(), + autoRegister: z.boolean().optional(), + autoStart: z.boolean().optional(), + baseName: z.string().min(1), + beginEndMeterValues: z.boolean().optional(), + chargeBoxSerialNumberPrefix: z.string().optional(), + chargePointModel: z.string().min(1), + chargePointSerialNumberPrefix: z.string().optional(), + chargePointVendor: z.string().min(1), + commandsSupport: CommandsSupportSchema.optional(), + Configuration: OcppConfigurationSchema.optional(), + Connectors: z.record(z.string().regex(/^\d+$/), ConnectorStatusSchema).optional(), + currentOutType: z.string().optional(), + customValueLimitationMeterValues: z.boolean().optional(), + enableStatistics: z.boolean().optional(), + Evses: z.record(z.string().regex(/^\d+$/), EvseTemplateSchema).optional(), + firmwareUpgrade: FirmwareUpgradeSchema.optional(), + firmwareVersion: z.string().optional(), + firmwareVersionPattern: z.string().optional(), + fixedName: z.boolean().optional(), + iccid: z.string().optional(), + idTagsFile: z.string().optional(), + imsi: z.string().optional(), + mainVoltageMeterValues: z.boolean().optional(), + messageTriggerSupport: z.record(z.string(), z.boolean()).optional(), + meteringPerTransaction: z.boolean().optional(), + meterSerialNumberPrefix: z.string().optional(), + meterType: z.string().optional(), + nameSuffix: z.string().optional(), + numberOfConnectors: z.union([z.number(), z.array(z.number())]).optional(), + numberOfPhases: z.number().optional(), + ocppPersistentConfiguration: z.boolean().optional(), + ocppProtocol: z.string().optional(), + ocppStrictCompliance: z.boolean().optional(), + ocppVersion: z.string().optional(), + outOfOrderEndMeterValues: z.boolean().optional(), + phaseLineToLineVoltageMeterValues: z.boolean().optional(), + postTransactionDelay: z.number().optional(), + power: z.union([z.number(), z.array(z.number())]).optional(), + powerSharedByConnectors: z.boolean().optional(), + powerUnit: z.string().optional(), + randomConnectors: z.boolean().optional(), + reconnectExponentialDelay: z.boolean().optional(), + registrationMaxRetries: z.number().optional(), + remoteAuthorization: z.boolean().optional(), + resetTime: z.number().optional(), + stationInfoPersistentConfiguration: z.boolean().optional(), + stopTransactionsOnStopped: z.boolean().optional(), + supervisionPassword: z.string().optional(), + supervisionUrlOcppConfiguration: z.boolean().optional(), + supervisionUrlOcppKey: z.string().optional(), + supervisionUrls: z.union([z.string(), z.array(z.string())]).optional(), + supervisionUser: z.string().optional(), + templateHash: z.string().optional(), + transactionDataMeterValues: z.boolean().optional(), + useConnectorId0: z.boolean().optional(), + voltageOut: z.number().optional(), + wsOptions: WsOptionsSchema.optional(), + x509Certificates: z.record(z.string(), z.string()).optional(), +}) + +const LEGACY_KEYS = [ + 'authorizationFile', + 'mustAuthorizeAtRemoteStart', + 'payloadSchemaValidation', + 'supervisionUrl', +] as const + +/** + * TemplateSchema — validates that the template has valid structure and + * defines either Connectors OR Evses (not both, not neither). + */ +export const TemplateSchema = BaseTemplateSchema.superRefine((template, ctx) => { + for (const legacyKey of LEGACY_KEYS) { + if ((template as Record)[legacyKey] !== undefined) { + ctx.addIssue({ + code: 'custom', + message: `Deprecated template key '${legacyKey}' is not allowed at $schemaVersion ${CURRENT_SCHEMA_VERSION.toString()}. Remove '$schemaVersion' to trigger automatic v0 migration, or replace the key with its v1 equivalent`, + path: [legacyKey], + }) + } + } + const hasConnectors = template.Connectors != null + const hasEvses = template.Evses != null + if (hasConnectors && hasEvses) { + ctx.addIssue({ + code: 'custom', + message: 'Template must define Connectors OR Evses, not both', + path: ['Connectors'], + }) + } + // Validate Evses topology (OCPP 2.0.1 §7.2 constraints) + if (hasEvses && template.Evses != null) { + for (const [evseKey, evse] of Object.entries(template.Evses)) { + const evseId = Number(evseKey) + const connectorIds = Object.keys(evse.Connectors).map(Number) + if (evseId === 0) { + for (const connectorId of connectorIds) { + if (connectorId !== 0) { + ctx.addIssue({ + code: 'custom', + message: `EVSE 0 has invalid connector id ${connectorId.toString()}, only connector id 0 is allowed (OCPP 2.0.1 §7.2)`, + path: ['Evses', evseKey, 'Connectors', connectorId.toString()], + }) + } + } + } else if (evseId > 0) { + for (const connectorId of connectorIds) { + if (connectorId < 1) { + ctx.addIssue({ + code: 'custom', + message: `EVSE ${evseId.toString()} has invalid connector id ${connectorId.toString()}, connector ids must start at 1 (OCPP 2.0.1 §7.2)`, + path: ['Evses', evseKey, 'Connectors', connectorId.toString()], + }) + } + } + } + } + } +}) + +/** + * StrictTemplateSchema — rejects unknown keys. For CI strict mode. + */ +export const StrictTemplateSchema = BaseTemplateSchema.strict() + +// --------------------------------------------------------------- +// Exported sub-schemas for reuse +// --------------------------------------------------------------- +export { ConnectorsVariant, EvsesVariant } diff --git a/src/charging-station/TemplateValidation.ts b/src/charging-station/TemplateValidation.ts new file mode 100644 index 00000000..1dc2d172 --- /dev/null +++ b/src/charging-station/TemplateValidation.ts @@ -0,0 +1,146 @@ +import type { ZodError } from 'zod' + +import type { ChargingStationTemplate } from '../types/index.js' + +import { BaseError } from '../exception/index.js' +import { isEmpty, isNotEmptyString, logger } from '../utils/index.js' +import { getMaxConfiguredNumberOfConnectors } from './Helpers.js' +import { applyMigration, coerceVersion, CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js' +import { TemplateSchema } from './TemplateSchema.js' + +const moduleName = 'TemplateValidation' + +/** + * Error thrown when a charging station template fails Zod validation. + * Includes structured field errors and migration context for diagnostics. + */ +export class TemplateValidationError extends BaseError { + public readonly fieldErrors: { message: string; path: string }[] + public readonly filePath: string + public readonly migratedFrom?: number + + public constructor (zodError: ZodError, context: { filePath: string; migratedFrom?: number }) { + const fieldErrors = zodError.issues.map(issue => ({ + message: issue.message, + path: issue.path.join('.'), + })) + const fieldSummary = fieldErrors + .map(e => ` - ${e.path !== '' ? e.path : '(root)'}: ${e.message}`) + .join('\n') + const migrationNote = + context.migratedFrom != null + ? ` (migrated from v${context.migratedFrom.toString()} → v${CURRENT_SCHEMA_VERSION.toString()})` + : '' + super( + `${moduleName}: Template validation failed for '${context.filePath}'${migrationNote}:\n${fieldSummary}` + ) + this.filePath = context.filePath + this.fieldErrors = fieldErrors + this.migratedFrom = context.migratedFrom + } +} + +/** + * Validate a parsed template object through the migration → validation → transform pipeline. + * @param parsed - Raw parsed JSON value (any type — guarded internally) + * @param filePath - Template file path (for error messages) + * @returns Validated and transformed ChargingStationTemplate + */ +export const validateTemplate = (parsed: unknown, filePath: string): ChargingStationTemplate => { + if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new BaseError( + `${moduleName}.validateTemplate: Invalid charging station template payload (not a JSON object) in template file ${filePath}` + ) + } + if (isEmpty(parsed)) { + throw new BaseError( + `${moduleName}.validateTemplate: Empty charging station information from template file ${filePath}` + ) + } + // Clone before mutating $schemaVersion below and inside applyMigration, + // so the caller's parsed JSON stays untouched. + const parsedRecord = structuredClone(parsed) as Record + + const version = coerceVersion(parsedRecord.$schemaVersion) + parsedRecord.$schemaVersion = version + const migratedFrom = version < CURRENT_SCHEMA_VERSION ? version : undefined + const migrated = + migratedFrom != null ? applyMigration(version, parsedRecord, filePath) : parsedRecord + + const result = TemplateSchema.safeParse(migrated) + if (!result.success) { + throw new TemplateValidationError(result.error, { filePath, migratedFrom }) + } + + return transformTemplate(result.data, filePath) +} + +/** + * Post-validation transform. + * + * Forces `randomConnectors=true` when the worst-case configured connector + * count (max of `numberOfConnectors[]`, or its scalar value) exceeds the + * available connector definitions in the template — runtime random pick + * can hit any value of the array, so any value above the available count + * requires `randomConnectors=true` to be safe under any pick. + * + * Warns about missing `idTagsFile` (advisory, non-fatal). + * + * Warnings fire once per `(templateFile, schemaVersion)` cache miss, + * not per station instance. + * + * Connector-count diagnostics fire only for the `Connectors` topology; + * the `Evses` topology does not currently emit equivalent warnings. + * @param validated - Schema-validated template data + * @param filePath - Template file path (for log messages) + * @returns Transformed ChargingStationTemplate + */ +function transformTemplate ( + validated: Record, + filePath: string +): ChargingStationTemplate { + if ( + validated.idTagsFile == null || + (typeof validated.idTagsFile === 'string' && !isNotEmptyString(validated.idTagsFile)) + ) { + logger.warn( + `${moduleName}.transformTemplate: Missing id tags file in template file ${filePath}. That can lead to issues with the Automatic Transaction Generator` + ) + } + + if (validated.Connectors != null && typeof validated.Connectors === 'object') { + const connectors = validated.Connectors as Record + const templateMaxConnectors = Object.keys(connectors).length + const templateMaxAvailableConnectors = + connectors['0'] != null ? templateMaxConnectors - 1 : templateMaxConnectors + + const configuredMaxConnectors = + getMaxConfiguredNumberOfConnectors( + validated.numberOfConnectors as number | readonly number[] | undefined + ) ?? templateMaxAvailableConnectors + + if ( + configuredMaxConnectors > templateMaxAvailableConnectors && + validated.randomConnectors !== true + ) { + logger.warn( + `${moduleName}.transformTemplate: Number of connectors (${configuredMaxConnectors.toString()}) exceeds the number of connector configurations (${templateMaxAvailableConnectors.toString()}) in template ${filePath}, forcing random connector configurations affectation` + ) + validated.randomConnectors = true + } + + if (templateMaxConnectors === 0) { + logger.warn( + `${moduleName}.transformTemplate: Charging station information from template ${filePath} with empty connectors configuration` + ) + } + + if (configuredMaxConnectors <= 0) { + logger.warn( + `${moduleName}.transformTemplate: Charging station information from template ${filePath} with ${configuredMaxConnectors.toString()} connectors` + ) + } + } + + return validated as unknown as ChargingStationTemplate +} diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 98f706ab..0cc36082 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -34,7 +34,7 @@ export const logPrefix = (prefixString = ''): string => { /** * Formats a log prefix for direct concatenation with a module/method tag. - * @param logPrefixFn - Prefix-producing function. Defaults to `logPrefix` (timestamp only) so callers without a + * @param logPrefixFn - Prefix-producing function. Defaults to `logPrefix` so callers without a * module-specific prefix still emit a timestamped log line. * @returns The prefix followed by a single trailing space (e.g. `" "`). The trailing space is part of the * contract: call sites concatenate the result directly with the message body, e.g. diff --git a/tests/charging-station/Helpers.test.ts b/tests/charging-station/Helpers.test.ts index 9c5b8001..668de1d3 100644 --- a/tests/charging-station/Helpers.test.ts +++ b/tests/charging-station/Helpers.test.ts @@ -10,15 +10,16 @@ import { checkChargingStationState, checkConfiguration, checkStationInfoConnectorStatus, - checkTemplate, getBootConnectorStatus, getChargingStationId, getHashId, + getMaxConfiguredNumberOfConnectors, getMaxNumberOfEvses, getPhaseRotationValue, hasPendingReservation, hasPendingReservations, hasReservationExpired, + pickConfiguredNumberOfConnectors, resetConnectorStatus, setChargingStationOptions, validateStationInfo, @@ -627,30 +628,6 @@ await describe('Helpers', async () => { assert.strictEqual(getMaxNumberOfEvses({}), 0) }) - await it('should throw for undefined or empty template', t => { - // Arrange - const warnMock = t.mock.method(logger, 'warn') - const errorMock = t.mock.method(logger, 'error') - - // Act & Assert - assert.throws( - () => { - checkTemplate(undefined, 'log prefix |', 'test-template.json') - }, - { message: /Failed to read charging station template file test-template\.json/ } - ) - assert.strictEqual(errorMock.mock.calls.length, 1) - assert.throws( - () => { - checkTemplate({} as ChargingStationTemplate, 'log prefix |', 'test-template.json') - }, - { message: /Empty charging station information from template file test-template\.json/ } - ) - assert.strictEqual(errorMock.mock.calls.length, 2) - checkTemplate(chargingStationTemplate, 'log prefix |', 'test-template.json') - assert.strictEqual(warnMock.mock.calls.length, 1) - }) - await it('should throw for undefined or empty configuration', t => { // Arrange const errorMock = t.mock.method(logger, 'error') @@ -987,4 +964,46 @@ await describe('Helpers', async () => { assert.strictEqual(connectorStatus.MeterValues.length, 1) }) }) + + await describe('getMaxConfiguredNumberOfConnectors', async () => { + await it('should return undefined for undefined input', () => { + assert.strictEqual(getMaxConfiguredNumberOfConnectors(undefined), undefined) + }) + + await it('should return undefined for empty array', () => { + assert.strictEqual(getMaxConfiguredNumberOfConnectors([]), undefined) + }) + + await it('should return the number itself for scalar input', () => { + assert.strictEqual(getMaxConfiguredNumberOfConnectors(3), 3) + }) + + await it('should return the worst-case (max) of a non-empty array', () => { + assert.strictEqual(getMaxConfiguredNumberOfConnectors([2, 4, 6]), 6) + assert.strictEqual(getMaxConfiguredNumberOfConnectors([1]), 1) + assert.strictEqual(getMaxConfiguredNumberOfConnectors([5, 1, 3]), 5) + }) + }) + + await describe('pickConfiguredNumberOfConnectors', async () => { + await it('should return undefined for undefined input', () => { + assert.strictEqual(pickConfiguredNumberOfConnectors(undefined), undefined) + }) + + await it('should return undefined for empty array', () => { + assert.strictEqual(pickConfiguredNumberOfConnectors([]), undefined) + }) + + await it('should return the number itself for scalar input', () => { + assert.strictEqual(pickConfiguredNumberOfConnectors(3), 3) + }) + + await it('should return one of the array elements for non-empty array', () => { + const candidates = [2, 4, 6] + for (let i = 0; i < 50; i++) { + const picked = pickConfiguredNumberOfConnectors(candidates) + assert.ok(picked != null && candidates.includes(picked)) + } + }) + }) }) diff --git a/tests/charging-station/TemplateMigrations.test.ts b/tests/charging-station/TemplateMigrations.test.ts new file mode 100644 index 00000000..bd3d9b84 --- /dev/null +++ b/tests/charging-station/TemplateMigrations.test.ts @@ -0,0 +1,149 @@ +/** + * @file Tests for TemplateMigrations + * @description Unit tests for schema version coercion and migration functions + */ + +import assert from 'node:assert/strict' +import { afterEach, describe, it } from 'node:test' + +import { + applyMigration, + coerceVersion, + CURRENT_SCHEMA_VERSION, +} from '../../src/charging-station/TemplateMigrations.js' +import { logger } from '../../src/utils/index.js' +import { mockLoggerWarnDebug, standardCleanup } from '../helpers/TestLifecycleHelpers.js' +import { buildLegacyTemplate } from './helpers/TemplateFixtures.js' + +await describe('TemplateMigrations', async () => { + afterEach(() => { + standardCleanup() + }) + + 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) + }) + }) + + await describe('coerceVersion', async () => { + await it('should return 0 for null or undefined (legacy templates trigger v0 migration)', () => { + assert.strictEqual(coerceVersion(null), 0) + assert.strictEqual(coerceVersion(undefined), 0) + }) + + await it('should return the number for valid integer', () => { + assert.strictEqual(coerceVersion(1), 1) + assert.strictEqual(coerceVersion(0), 0) + }) + + await it('should parse string to number', () => { + assert.strictEqual(coerceVersion('1'), 1) + assert.strictEqual(coerceVersion('0'), 0) + }) + + await it('should throw for non-numeric string', () => { + assert.throws(() => coerceVersion('abc'), { message: /must be a non-negative integer/ }) + }) + + await it('should throw for negative value', () => { + assert.throws(() => coerceVersion(-1), { message: /must be a non-negative integer/ }) + }) + + await it('should throw for float value', () => { + assert.throws(() => coerceVersion(1.5), { message: /must be a non-negative integer/ }) + }) + + await it('should reject permissive numeric string forms', () => { + for (const bad of ['1.0', '0x1', ' 1 ', '1e0', '', '01a', '+1', '-1']) { + assert.throws( + () => coerceVersion(bad), + { message: /must be a non-negative integer/ }, + `should reject ${JSON.stringify(bad)}` + ) + } + }) + + await it('should use harmonized "non-negative integer" wording for all rejection branches', () => { + for (const bad of ['abc', Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5]) { + assert.throws(() => coerceVersion(bad), { message: /must be a non-negative integer/ }) + } + }) + + await it('should throw for future version', () => { + assert.throws(() => coerceVersion(CURRENT_SCHEMA_VERSION + 1), { + message: /is newer than supported version/, + }) + }) + + await it('should throw for object value', () => { + assert.throws(() => coerceVersion({}), { message: /Invalid \$schemaVersion value/ }) + }) + + await it('should throw for boolean value', () => { + assert.throws(() => coerceVersion(true), { message: /Invalid \$schemaVersion value/ }) + }) + }) + + await describe('applyMigration', async () => { + await it('should migrate v0 to v1 renaming all deprecated keys at once', t => { + mockLoggerWarnDebug(t, logger) + const template = buildLegacyTemplate({ + authorizationFile: 'tags.json', + mustAuthorizeAtRemoteStart: true, + payloadSchemaValidation: false, + supervisionUrl: 'ws://localhost:8080', + }) + + const result = applyMigration(0, template) + + assert.strictEqual(result.$schemaVersion, CURRENT_SCHEMA_VERSION) + assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080') + assert.strictEqual(result.idTagsFile, 'tags.json') + assert.strictEqual(result.remoteAuthorization, true) + assert.strictEqual(result.ocppStrictCompliance, false) + assert.strictEqual(result.supervisionUrl, undefined) + assert.strictEqual(result.authorizationFile, undefined) + assert.strictEqual(result.mustAuthorizeAtRemoteStart, undefined) + assert.strictEqual(result.payloadSchemaValidation, undefined) + }) + + for (const [deprecated, replacement, value] of [ + ['supervisionUrl', 'supervisionUrls', 'ws://localhost:8080'], + ['authorizationFile', 'idTagsFile', 'tags.json'], + ['payloadSchemaValidation', 'ocppStrictCompliance', false], + ['mustAuthorizeAtRemoteStart', 'remoteAuthorization', true], + ] as const) { + await it(`should migrate v0 renaming ${deprecated} to ${replacement}`, t => { + mockLoggerWarnDebug(t, logger) + const template = buildLegacyTemplate({ [deprecated]: value }) + + const result = applyMigration(0, template) + + assert.strictEqual(result[replacement], value) + assert.strictEqual(result[deprecated], undefined) + }) + } + + for (const [label, sourceVersion] of [ + ['unknown source version', 99], + ['source version equal to CURRENT_SCHEMA_VERSION (no-op boundary)', CURRENT_SCHEMA_VERSION], + ['negative source version', -1], + ] as const) { + await it(`should throw for ${label}`, () => { + assert.throws(() => applyMigration(sourceVersion, buildLegacyTemplate()), { + message: /No migration defined/, + }) + }) + } + + await it('should set $schemaVersion to CURRENT_SCHEMA_VERSION after migration', t => { + mockLoggerWarnDebug(t, logger) + + const result = applyMigration(0, buildLegacyTemplate()) + + assert.strictEqual(result.$schemaVersion, CURRENT_SCHEMA_VERSION) + }) + }) +}) diff --git a/tests/charging-station/TemplateSchema.test.ts b/tests/charging-station/TemplateSchema.test.ts new file mode 100644 index 00000000..3ba1e7c8 --- /dev/null +++ b/tests/charging-station/TemplateSchema.test.ts @@ -0,0 +1,324 @@ +/** + * @file Tests for TemplateSchema + * @description Unit tests for Zod template schema validation + */ + +import assert from 'node:assert/strict' +import { afterEach, describe, it } from 'node:test' + +import { CURRENT_SCHEMA_VERSION } from '../../src/charging-station/TemplateMigrations.js' +import { TemplateSchema } from '../../src/charging-station/TemplateSchema.js' +import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' +import { TEST_CHARGING_STATION_BASE_NAME } from './ChargingStationTestConstants.js' +import { buildMinimalTemplate } from './helpers/TemplateFixtures.js' + +await describe('TemplateSchema', async () => { + afterEach(() => { + standardCleanup() + }) + + await describe('required fields', async () => { + await it('should accept a minimal valid template with Connectors', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ + Connectors: { + 0: {}, + 1: { MeterValues: [] }, + }, + }) + ) + assert.ok(result.success) + assert.strictEqual(result.data.baseName, TEST_CHARGING_STATION_BASE_NAME) + }) + + for (const requiredField of ['baseName', 'chargePointModel', 'chargePointVendor'] as const) { + await it(`should reject missing ${requiredField}`, () => { + const template = buildMinimalTemplate() + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete template[requiredField] + const result = TemplateSchema.safeParse(template) + assert.ok(!result.success) + assert.ok(result.error.issues.some(i => i.path.includes(requiredField))) + }) + } + + await it('should reject empty baseName', () => { + const result = TemplateSchema.safeParse(buildMinimalTemplate({ baseName: '' })) + assert.ok(!result.success) + }) + }) + + await describe('$schemaVersion', async () => { + await it('should reject template missing $schemaVersion (post-migration schema is strict)', () => { + const template = buildMinimalTemplate() + + delete template.$schemaVersion + const result = TemplateSchema.safeParse(template) + assert.ok(!result.success) + assert.ok(result.error.issues.some(i => i.path.includes('$schemaVersion'))) + }) + + await it('should accept explicit $schemaVersion equal to CURRENT_SCHEMA_VERSION', () => { + const result = TemplateSchema.safeParse(buildMinimalTemplate()) + assert.ok(result.success) + assert.strictEqual(result.data.$schemaVersion, CURRENT_SCHEMA_VERSION) + }) + + await it('should reject $schemaVersion not equal to CURRENT_SCHEMA_VERSION', () => { + const result = TemplateSchema.safeParse(buildMinimalTemplate({ $schemaVersion: 0 })) + assert.ok(!result.success) + assert.ok(result.error.issues.some(i => i.path.includes('$schemaVersion'))) + }) + }) + + await describe('deprecated keys rejection', async () => { + for (const [legacyKey, legacyValue] of [ + ['supervisionUrl', 'ws://localhost:8080'], + ['authorizationFile', 'tags.json'], + ['mustAuthorizeAtRemoteStart', true], + ['payloadSchemaValidation', false], + ] as const) { + await it(`should reject template containing legacy ${legacyKey} key`, () => { + const result = TemplateSchema.safeParse(buildMinimalTemplate({ [legacyKey]: legacyValue })) + assert.ok(!result.success) + assert.ok( + result.error.issues.some( + i => i.path.includes(legacyKey) && i.message.includes('Deprecated') + ) + ) + }) + } + }) + + await describe('typed sub-schemas', async () => { + await it('should accept structured signedMeterValue with vendor extensions', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ + Connectors: { + 0: {}, + 1: { + MeterValues: [ + { + signedMeterValue: { + encodingMethod: 'Other', + publicKey: 'pk', + signedMeterData: 'data', + signingMethod: 'Other', + vendorField: 'preserved', + }, + unit: 'Wh', + }, + ], + }, + }, + }) + ) + assert.ok(result.success) + }) + + await it('should reject signedMeterValue with non-string encodingMethod', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ + Connectors: { + 0: {}, + 1: { + MeterValues: [ + { + signedMeterValue: { encodingMethod: 42 }, + unit: 'Wh', + }, + ], + }, + }, + }) + ) + assert.ok(!result.success) + }) + + await it('should accept structured wsOptions with known fields', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ + wsOptions: { + handshakeTimeout: 5000, + headers: { 'X-Custom': 'value' }, + rejectUnauthorized: false, + }, + }) + ) + assert.ok(result.success) + }) + + await it('should reject wsOptions with non-object headers', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ wsOptions: { headers: 'not an object' } }) + ) + assert.ok(!result.success) + }) + + await it('should accept numeric wsOptions header value (Node OutgoingHttpHeader)', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ wsOptions: { headers: { 'X-Forwarded-Port': 8080 } } }) + ) + assert.ok(result.success) + }) + + await it('should accept multi-value wsOptions header (string array)', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ + wsOptions: { headers: { Accept: ['application/json', 'text/plain'] } }, + }) + ) + assert.ok(result.success) + }) + + await it('should reject wsOptions header array with non-string element', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ + wsOptions: { headers: { Accept: ['application/json', 42] } }, + }) + ) + assert.ok(!result.success) + }) + + await it('should reject wsOptions header with empty field-name (RFC 9110 §5.1)', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ wsOptions: { headers: { '': 'value' } } }) + ) + assert.ok(!result.success) + }) + + await it('should reject wsOptions header with boolean value', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ wsOptions: { headers: { 'X-Foo': true } } }) + ) + assert.ok(!result.success) + }) + }) + + await describe('topology discrimination', async () => { + await it('should reject template with both Connectors and Evses', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ + Connectors: { 0: {} }, + Evses: { 0: { Connectors: { 0: {} } } }, + }) + ) + assert.ok(!result.success) + assert.ok(result.error.issues.some(i => i.message.includes('Connectors OR Evses, not both'))) + }) + + await it('should accept template with only Connectors', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ Connectors: { 0: {}, 1: {} } }) + ) + assert.ok(result.success) + }) + + await it('should accept template with only Evses', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ + Evses: { + 0: { Connectors: { 0: {} } }, + 1: { Connectors: { 1: {} } }, + }, + }) + ) + assert.ok(result.success) + }) + + await it('should accept template with neither Connectors nor Evses', () => { + const result = TemplateSchema.safeParse(buildMinimalTemplate()) + assert.ok(result.success) + }) + }) + + await describe('Evses validation (OCPP 2.0.1 §7.2)', async () => { + await it('should reject EVSE 0 with non-zero connector id', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ Evses: { 0: { Connectors: { 1: {} } } } }) + ) + assert.ok(!result.success) + assert.ok( + result.error.issues.some(i => i.message.includes('EVSE 0 has invalid connector id')) + ) + }) + + await it('should reject EVSE > 0 with connector id 0', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ Evses: { 1: { Connectors: { 0: {} } } } }) + ) + assert.ok(!result.success) + assert.ok(result.error.issues.some(i => i.message.includes('connector ids must start at 1'))) + }) + + await it('should accept valid EVSE configuration', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ + Evses: { + 0: { Connectors: { 0: {} } }, + 1: { Connectors: { 1: {} } }, + 2: { Connectors: { 2: {} } }, + }, + }) + ) + assert.ok(result.success) + }) + }) + + await describe('MeterValues normalization', async () => { + await it('should accept string value in MeterValues', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ + Connectors: { + 0: {}, + 1: { MeterValues: [{ unit: 'Wh', value: '42' }] }, + }, + }) + ) + assert.ok(result.success) + const mv = result.data.Connectors?.['1']?.MeterValues?.[0] + assert.strictEqual(mv?.value, '42') + }) + + await it('should coerce number value to string in MeterValues', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ + Connectors: { + 0: {}, + 1: { MeterValues: [{ unit: 'Wh', value: 0 }] }, + }, + }) + ) + assert.ok(result.success) + const mv = result.data.Connectors?.['1']?.MeterValues?.[0] + assert.strictEqual(mv?.value, '0') + }) + }) + + await describe('looseObject behavior', async () => { + await it('should tolerate unknown top-level keys', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ unknownField: 'should be preserved' }) + ) + assert.ok(result.success) + assert.strictEqual( + (result.data as Record).unknownField, + 'should be preserved' + ) + }) + }) + + await describe('connector key validation', async () => { + await it('should accept numeric string keys in Connectors', () => { + const result = TemplateSchema.safeParse( + buildMinimalTemplate({ Connectors: { 0: {}, 1: {}, 2: {} } }) + ) + assert.ok(result.success) + }) + + await it('should reject non-numeric keys in Connectors', () => { + const result = TemplateSchema.safeParse(buildMinimalTemplate({ Connectors: { abc: {} } })) + assert.ok(!result.success) + }) + }) +}) diff --git a/tests/charging-station/TemplateValidation.test.ts b/tests/charging-station/TemplateValidation.test.ts new file mode 100644 index 00000000..ab7397ff --- /dev/null +++ b/tests/charging-station/TemplateValidation.test.ts @@ -0,0 +1,305 @@ +/** + * @file Tests for TemplateValidation + * @description Unit tests for template validation pipeline, transforms, and error handling + */ + +import assert from 'node:assert/strict' +import { afterEach, describe, it } from 'node:test' +import { ZodError } from 'zod' + +import { CURRENT_SCHEMA_VERSION } from '../../src/charging-station/TemplateMigrations.js' +import { + TemplateValidationError, + validateTemplate, +} from '../../src/charging-station/TemplateValidation.js' +import { BaseError } from '../../src/exception/index.js' +import { logger } from '../../src/utils/index.js' +import { mockLoggerWarnDebug, standardCleanup } from '../helpers/TestLifecycleHelpers.js' +import { TEST_CHARGING_STATION_BASE_NAME } from './ChargingStationTestConstants.js' +import { buildLegacyTemplate, buildMinimalTemplate } from './helpers/TemplateFixtures.js' + +await describe('TemplateValidation', async () => { + afterEach(() => { + standardCleanup() + }) + + await describe('validateTemplate', async () => { + await it('should validate a minimal valid template', t => { + t.mock.method(logger, 'warn') + const parsed = buildMinimalTemplate({ Connectors: { 0: {}, 1: {} } }) + + const result = validateTemplate(parsed, 'test.json') + + assert.strictEqual(result.baseName, TEST_CHARGING_STATION_BASE_NAME) + }) + + await it('should accept string "$schemaVersion": "1" at current version (no-migration path)', t => { + t.mock.method(logger, 'warn') + const parsed = buildMinimalTemplate({ + $schemaVersion: '1', + Connectors: { 0: {}, 1: {} }, + }) + + const result = validateTemplate(parsed, 'string-version.json') + + assert.strictEqual(result.baseName, TEST_CHARGING_STATION_BASE_NAME) + assert.strictEqual( + (result as unknown as Record).$schemaVersion, + CURRENT_SCHEMA_VERSION, + '$schemaVersion should be normalized to numeric CURRENT_SCHEMA_VERSION' + ) + }) + + await it('should not mutate the caller-supplied parsed object (immutability boundary)', t => { + mockLoggerWarnDebug(t, logger) + const parsed = buildLegacyTemplate({ + Connectors: { 0: {}, 1: {} }, + supervisionUrl: 'ws://localhost:8080', + }) + const before = structuredClone(parsed) + + validateTemplate(parsed, 'immutable.json') + + assert.deepStrictEqual(parsed, before) + }) + + await it('should throw BaseError for empty template', () => { + assert.throws( + () => validateTemplate({}, 'test.json'), + (error: unknown) => + error instanceof BaseError && + error.message.includes('Empty charging station information from template file') + ) + }) + + await it('should throw TemplateValidationError for invalid template', () => { + assert.throws( + () => validateTemplate({ baseName: '' }, 'test.json'), + (error: unknown) => error instanceof TemplateValidationError + ) + }) + + await it('should include filePath and fieldErrors in TemplateValidationError', () => { + try { + validateTemplate( + { baseName: '', chargePointModel: 'X', chargePointVendor: 'Y' }, + 'my-template.json' + ) + assert.fail('Expected TemplateValidationError') + } catch (error) { + assert.ok(error instanceof TemplateValidationError) + assert.strictEqual(error.filePath, 'my-template.json') + assert.ok(Array.isArray(error.fieldErrors)) + assert.ok(error.fieldErrors.length > 0) + } + }) + + await it('should apply migration for v0 templates', t => { + mockLoggerWarnDebug(t, logger) + const parsed = buildLegacyTemplate({ + $schemaVersion: 0, + Connectors: { 0: {}, 1: {} }, + supervisionUrl: 'ws://localhost:8080', + }) + + const result = validateTemplate(parsed, 'test.json') + + assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080') + }) + + await it('should auto-migrate template missing $schemaVersion (legacy v0 default)', t => { + mockLoggerWarnDebug(t, logger) + const parsed = buildLegacyTemplate({ + authorizationFile: 'tags.json', + Connectors: { 0: {}, 1: {} }, + mustAuthorizeAtRemoteStart: true, + payloadSchemaValidation: false, + supervisionUrl: 'ws://localhost:8080', + }) + + const result = validateTemplate(parsed, 'legacy.json') + + assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080') + assert.strictEqual(result.idTagsFile, 'tags.json') + assert.strictEqual(result.remoteAuthorization, true) + assert.strictEqual(result.ocppStrictCompliance, false) + const raw = result as unknown as Record + assert.strictEqual(raw.supervisionUrl, undefined) + assert.strictEqual(raw.authorizationFile, undefined) + assert.strictEqual(raw.mustAuthorizeAtRemoteStart, undefined) + assert.strictEqual(raw.payloadSchemaValidation, undefined) + }) + + for (const [label, payload] of [ + ['null', null], + ['string', 'a string'], + ['array', [1, 2, 3]], + ] as const) { + await it(`should throw BaseError for ${label} parsed payload`, () => { + assert.throws( + () => validateTemplate(payload, `${label}.json`), + (error: unknown) => + error instanceof BaseError && + error.message.includes('Invalid charging station template payload (not a JSON object)') + ) + }) + } + + for (const [legacyKey, legacyValue] of [ + ['supervisionUrl', 'ws://localhost:8080'], + ['mustAuthorizeAtRemoteStart', true], + ['payloadSchemaValidation', false], + ] as const) { + await it(`should reject v1 template containing legacy ${legacyKey} key`, t => { + t.mock.method(logger, 'warn') + assert.throws( + () => + validateTemplate(buildMinimalTemplate({ [legacyKey]: legacyValue }), 'v1-legacy.json'), + (error: unknown) => + error instanceof TemplateValidationError && + error.fieldErrors.some(e => e.path === legacyKey && e.message.includes('Deprecated')) + ) + }) + } + + await it('should include "(migrated from vX → vY)" note in TemplateValidationError message', t => { + mockLoggerWarnDebug(t, logger) + try { + validateTemplate(buildLegacyTemplate({ $schemaVersion: 0, baseName: '' }), 'broken.json') + assert.fail('Expected TemplateValidationError') + } catch (error) { + assert.ok(error instanceof TemplateValidationError) + assert.strictEqual(error.migratedFrom, 0) + assert.match(error.message, /migrated from v0 → v1/) + } + }) + }) + + await describe('transformTemplate', async () => { + await it('should warn about missing idTagsFile', t => { + const warnMock = t.mock.method(logger, 'warn') + const parsed = buildMinimalTemplate() + + validateTemplate(parsed, 'test.json') + + const warnMessages = warnMock.mock.calls.map(c => + typeof c.arguments[0] === 'string' ? c.arguments[0] : '' + ) + assert.ok(warnMessages.some(m => m.includes('Missing id tags file'))) + }) + + await it('should force randomConnectors when scalar numberOfConnectors exceeds defined connectors', t => { + t.mock.method(logger, 'warn') + const parsed = buildMinimalTemplate({ + Connectors: { 0: {}, 1: {} }, + numberOfConnectors: 5, + randomConnectors: false, + }) + + const result = validateTemplate(parsed, 'test.json') + + assert.strictEqual(result.randomConnectors, true) + }) + + await it('should force randomConnectors when max(numberOfConnectors[]) exceeds defined connectors', t => { + t.mock.method(logger, 'warn') + const parsed = buildMinimalTemplate({ + Connectors: { 0: {}, 1: {}, 2: {} }, + numberOfConnectors: [2, 4, 6], + randomConnectors: false, + }) + + const result = validateTemplate(parsed, 'test.json') + + assert.strictEqual(result.randomConnectors, true) + }) + + await it('should not force randomConnectors when max(numberOfConnectors[]) does not exceed defined connectors', t => { + t.mock.method(logger, 'warn') + const parsed = buildMinimalTemplate({ + Connectors: { 0: {}, 1: {}, 2: {}, 3: {}, 4: {} }, + numberOfConnectors: [1, 2, 3, 4], + }) + + const result = validateTemplate(parsed, 'test.json') + + assert.notStrictEqual(result.randomConnectors, true) + }) + + await it('should not force randomConnectors when already true', t => { + t.mock.method(logger, 'warn') + const parsed = buildMinimalTemplate({ + Connectors: { 0: {}, 1: {} }, + numberOfConnectors: 5, + randomConnectors: true, + }) + + const result = validateTemplate(parsed, 'test.json') + + assert.strictEqual(result.randomConnectors, true) + }) + + await it('should not log error for empty Connectors map', t => { + const errorMock = t.mock.method(logger, 'error') + t.mock.method(logger, 'warn') + const parsed = buildMinimalTemplate({ Connectors: {} }) + + validateTemplate(parsed, 'test.json') + + const errorMessages = errorMock.mock.calls.map(c => + typeof c.arguments[0] === 'string' ? c.arguments[0] : '' + ) + assert.ok(!errorMessages.some(m => m.includes('no connectors configuration defined'))) + }) + }) + + await describe('TemplateValidationError', async () => { + await it('should be an instance of BaseError', () => { + const zodError = new ZodError([ + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['baseName'], + }, + ]) + const error = new TemplateValidationError(zodError, { filePath: 'test.json' }) + assert.ok(error instanceof BaseError) + assert.strictEqual(error.filePath, 'test.json') + assert.strictEqual(error.fieldErrors.length, 1) + assert.strictEqual(error.fieldErrors[0].path, 'baseName') + }) + + await it('should include migratedFrom when provided', () => { + const zodError = new ZodError([]) + const error = new TemplateValidationError(zodError, { + filePath: 'test.json', + migratedFrom: 0, + }) + assert.strictEqual(error.migratedFrom, 0) + }) + }) + + await describe('all template files round-trip', async () => { + await it('should validate all 15 station template files through the pipeline', async t => { + mockLoggerWarnDebug(t, logger) + const fs = await import('node:fs') + const path = await import('node:path') + const templateDir = path.join(import.meta.dirname, '../../src/assets/station-templates') + const files = fs.readdirSync(templateDir).filter(f => f.endsWith('.json')) + assert.strictEqual(files.length, 15) + + for (const file of files) { + const content = fs.readFileSync(path.join(templateDir, file), 'utf8') + const parsed = JSON.parse(content) as Record + const result = validateTemplate(parsed, file) + assert.ok(result, `Template ${file} should validate successfully`) + assert.strictEqual( + result.baseName.length > 0, + true, + `Template ${file} should have baseName` + ) + } + }) + }) +}) diff --git a/tests/charging-station/helpers/TemplateFixtures.ts b/tests/charging-station/helpers/TemplateFixtures.ts new file mode 100644 index 00000000..575d331e --- /dev/null +++ b/tests/charging-station/helpers/TemplateFixtures.ts @@ -0,0 +1,39 @@ +/** + * @file Shared template fixtures for schema/migration/validation tests. + */ + +import { CURRENT_SCHEMA_VERSION } from '../../../src/charging-station/TemplateMigrations.js' +import { + TEST_CHARGE_POINT_MODEL, + TEST_CHARGE_POINT_VENDOR, + TEST_CHARGING_STATION_BASE_NAME, +} from '../ChargingStationTestConstants.js' + +/** + * Build a minimal valid template at the current schema version. + * @param overrides - Fields to merge into the base template + * @returns A minimal template accepted by `TemplateSchema` + */ +export const buildMinimalTemplate = ( + overrides: Record = {} +): Record => ({ + $schemaVersion: CURRENT_SCHEMA_VERSION, + baseName: TEST_CHARGING_STATION_BASE_NAME, + chargePointModel: TEST_CHARGE_POINT_MODEL, + chargePointVendor: TEST_CHARGE_POINT_VENDOR, + ...overrides, +}) + +/** + * Build a minimal pre-current-version template for migration-path tests. + * @param overrides - Fields to merge into the base template + * @returns A minimal template without `$schemaVersion` + */ +export const buildLegacyTemplate = ( + overrides: Record = {} +): Record => ({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + chargePointModel: TEST_CHARGE_POINT_MODEL, + chargePointVendor: TEST_CHARGE_POINT_VENDOR, + ...overrides, +}) diff --git a/tests/helpers/TestLifecycleHelpers.ts b/tests/helpers/TestLifecycleHelpers.ts index e3a5f8b5..5d4820de 100644 --- a/tests/helpers/TestLifecycleHelpers.ts +++ b/tests/helpers/TestLifecycleHelpers.ts @@ -256,6 +256,21 @@ export function createTimerScope ( } } +/** + * Install no-op spies on the logger warn and debug methods. + * @param t - Test context from node:test. + * @param logger - Logger instance to spy on. + * @param logger.debug - Logger debug method + * @param logger.warn - Logger warn method + */ +export function mockLoggerWarnDebug ( + t: MockContext, + logger: { debug: unknown; warn: unknown } +): void { + t.mock.method(logger, 'warn') + t.mock.method(logger, 'debug') +} + /** * Setup a connector with an active transaction * -- 2.43.0