From e12ddd9945822e2e028c13f7fb079634219c5fe5 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 17 Jun 2026 18:49:22 +0200 Subject: [PATCH] feat(simulator): add forceTransactionOnInvalidIdToken template flag (#1907) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * feat(simulator): add forceTransactionOnInvalidIdToken template flag Allow station-initiated transactions to continue even when CSMS replies with a non-Accepted IdToken status (OCPP 1.6 idTagInfo.status, OCPP 2.0.1 idTokenInfo.status). Default false preserves spec-compliant behavior. The flag is non-spec-compliant by design (violates OCPP 2.0.1 E05.FR.09, E05.FR.10, E06.FR.04 when enabled) and is intended for testing edge-case charging station implementations that ignore CSMS rejection. JSDoc on the field warns explicitly; README entry flags it as a non-spec-compliant test override. Scope in OCPP 2.0.1 is limited to TransactionEvent eventType=Started; mid-transaction revocation (Updated/Ended event types) still triggers deauthorization regardless of the flag. The OCPP 2.0.1 \`StopTxOnInvalidId\` device-model variable is left untouched — the template flag short-circuits ahead of the variable's accounting branch. Tests: 16 new test cases across both OCPP namespaces covering force-on/off, mid-tx revocation preservation, override marker log presence, auth cache non-relaxation, pre-Start guard preservation, and full status-enum parity (8 OCPP 2.0.1 statuses). Closes #1826 * fix(types): restore fixedName and pin endedConnector non-null The forceTransactionOnInvalidIdToken JSDoc rewrite in 73e3358a accidentally deleted the adjacent fixedName?: boolean field on ChargingStationTemplate, breaking 30+ TS errors in CI (ChargingStationNameTemplate Pick keyed on fixedName). Local pnpm test does not run tsc --noEmit, so the regression was invisible until CI. Also fix a CI-only error in OCPP20ResponseService-ForceTxOnInvalid.test.ts: endedConnector is typed as ConnectorStatus | undefined; replace the optional-chain assertions with an explicit if/fail guard so TS narrows the type for the subsequent strict-equal assertions. * test(2.0): document deferred MV-pump fence and ConcurrentTx scope Phase-4 review-C MINOR-1: the 2.0-T2 test asserts that \`startUpdatedMeterValues\` is called but stubs the helper, so a wire-level regression where the interval binds to the wrong connector would not be caught. Phase 6 golden-set with the live mock CSMS will close that gap; add a TODO comment to make the deferral explicit. Phase-4 review-C NIT-1: the T7 enum-parity loop omits ConcurrentTx; this is correct (ConcurrentTx is not an IdToken rejection in OCPP 2.0.1) but deserves an inline comment to prevent a future contributor from adding it naively. * test(ocpp): tighten forceTransactionOnInvalidIdToken coverage and clarify docs Follow-up to #1907 applying post-review fixes from a 3-angle audit (production / types-schema-readme / tests-adversarial). Production (OCPP 2.0): - Simplify defensive `else if (overrideRejection && status !== Accepted)` to `else if (overrideRejection)` in handleResponseTransactionEvent; the second predicate is entailed by the enclosing if/else. - Trim duplicated 5-line comment block down to a 2-line pointer to the canonical JSDoc on ChargingStationTemplate.forceTransactionOnInvalidIdToken. Documentation: - ChargingStationTemplate JSDoc: disambiguate from the OCPP variables StopTransactionOnInvalidId (1.6) and StopTxOnInvalidId (2.0.1) which control mid-transaction stop on revocation and have inverse polarity; state independence from ocppStrictCompliance. - README row: same disambiguation appended to the cell. Tests (OCPP 1.6 ForceTxOnInvalid): - T5 (pre-Start guard): change response status from Accepted to Invalid so the test exercises the flag-vs-guard interaction it claims to lock. - T6 (new): status-enum parity loop via Object.values(OCPP16AuthorizationStatus) excluding Accepted and ConcurrentTx (3 instances: Blocked, Expired, Invalid). - T2: replicate the Phase-6 fake-timer-fence TODO from the sibling 2.0 file (MV pump observability disclosure). - Rename test titles to should [verb] per tests/TEST_STYLE_GUIDE.md \xa71. Tests (OCPP 2.0 ForceTxOnInvalid): - buildTransactionEventRequest: add optional idToken parameter to exercise the auth-cache update path at handleResponseTransactionEvent. - T8 (new): C10.FR.01/04/05 auth-cache invariant test, parametric over [flag=true, flag=false] (2 instances) asserting updateAuthorizationCache is called with the supplied idToken and the CSMS-replied idTokenInfo. - T4 (Ended): add deauth call-count assertion (=== 0); locks the cleanup-runs-before-deauth-gate ordering invariant. The deauth path is reached but no-ops because cleanupEndedTransaction clears transactionId first, so getConnectorIdByTransactionId returns null. A regression that reorders cleanup after the gate (or preserves transactionId on Ended) flips this to 1. - T7 (parity): replace static 8-element array with Object.values(OCPP20AuthorizationStatusEnumType).filter(...). Same 8 instances today; future enum additions are auto-covered. - T6: split into 2 it() blocks (flag-on / flag-off) eliminating the mid-test standardCleanup+remock pattern. Each block additionally asserts that the override-marker warn-log is NOT emitted on null idTokenInfo, locking the invariant against an A6-style regression where the override-marker else-if is hoisted outside the outer null guard. - Rename test titles to should [verb]. Verification: - pnpm typecheck exit 0 - pnpm lint exit 0 - pnpm test: 0 fail across the full suite - 23/23 GREEN on the two ForceTxOnInvalid files (was 17/17; +6 new) - Three mutation experiments confirm regression-detection strength: * Drop `|| forceTransactionOnInvalidIdToken` from OCPP16 gate (line 427): T2 + 3 parity tests fail with `false !== true` on transactionStarted. * Replace `if (requestPayload.idToken != null)` with `if (false)` in OCPP20 cache update (line 519): T8 (both flag states) fails `0 !== 1`. * Bypass the OCPP16 unauthorized-remote-start guard (lines 315-329): T5 only fails (regression-localized to that scenario). * docs(ocpp): note ocppStrictCompliance independence and clarify Started log Follow-up to review v2 of #1907 closing the two actionable findings (B4 Low, N1 nit). Two remaining v2 nits (B5 multi-line JSDoc, R3 test cast) are intentionally deferred — rationale in /tmp/pr-1907-review-v2/fixes-design.md. B4 — ChargingStationTemplate.forceTransactionOnInvalidIdToken JSDoc and the matching README row both gain a one-clause disambiguation: "Independent of `ocppStrictCompliance` (operates on response handling, not schema validation)". Mirrors the pattern used by the sibling `outOfOrderEndMeterValues` row (which cites ITS `ocppStrictCompliance` coupling), preventing readers from inferring a non-existent coupling for the new flag. N1 — OCPP20ResponseService.handleResponseTransactionEvent override warn log now reads "...on eventType=Started despite..." (was "...on Started despite..."). The `eventType=` prefix removes ambiguity with OCPP connector state names (Charging, Available) and aligns the log vocabulary with the JSDoc, the inline reference comment, and the test fixture (`requestPayload.eventType === OCPP20TransactionEventEnumType.Started`). The override-marker substring `forceTransactionOnInvalidIdToken=true` is preserved verbatim, so the four test assertions that grep for it remain unaffected. Verification: - pnpm typecheck exit 0 - pnpm lint exit 0 - pnpm test: 0 fail across the full suite - 23/23 GREEN on the two ForceTxOnInvalid files (count unchanged; doc-only changes) - README table re-aligned by prettier on save (column padding shifted 421 -> 460 chars to fit the longer description); diff is mechanical whitespace, content change is one clause. --- README.md | 127 ++--- src/charging-station/TemplateSchema.ts | 1 + .../ocpp/1.6/OCPP16ResponseService.ts | 38 +- .../ocpp/2.0/OCPP20ResponseService.ts | 20 +- src/types/ChargingStationTemplate.ts | 11 + src/utils/Constants.ts | 1 + ...16ResponseService-ForceTxOnInvalid.test.ts | 283 +++++++++++ ...20ResponseService-ForceTxOnInvalid.test.ts | 472 ++++++++++++++++++ 8 files changed, 873 insertions(+), 80 deletions(-) create mode 100644 tests/charging-station/ocpp/1.6/OCPP16ResponseService-ForceTxOnInvalid.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20ResponseService-ForceTxOnInvalid.test.ts diff --git a/README.md b/README.md index ef5858c2..495532a4 100644 --- a/README.md +++ b/README.md @@ -200,69 +200,70 @@ But the modifications to test have to be done to the files in the build target d **src/assets/station-templates/\.json**: -| Key | Value(s) | Default Value | Value type | Description | -| ---------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| supervisionUrls | | [] | string \| string[] | string or strings array containing connection URIs to OCPP-J servers | -| 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 | -| 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 | -| ocppStrictCompliance | true/false | true | boolean | enable strict adherence to the OCPP version and protocol specifications with OCPP commands PDU validation against [OCA](https://www.openchargealliance.org/) JSON schemas | -| ocppPersistentConfiguration | true/false | true | boolean | enable persistent OCPP parameters storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | -| stationInfoPersistentConfiguration | true/false | true | boolean | enable persistent station information and specifications storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | -| automaticTransactionGeneratorPersistentConfiguration | true/false | true | boolean | enable persistent automatic transaction generator configuration storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | -| wsOptions | | {} | ClientOptions & ClientRequestArgs | [ws](https://github.com/websockets/ws) and node.js [http](https://nodejs.org/api/http.html) clients options intersection | -| idTagsFile | | undefined | string | RFID tags list file relative to [src/assets](./src/assets) path | -| iccid | | undefined | string | SIM card ICCID | -| imsi | | undefined | string | SIM card IMSI | -| baseName | | undefined | string | base name to build charging stations id | -| nameSuffix | | undefined | string | name suffix to build charging stations id | -| fixedName | true/false | false | boolean | use the 'baseName' as the charging stations unique name | -| chargePointModel | | undefined | string | charging stations model | -| chargePointVendor | | undefined | string | charging stations vendor | -| chargePointSerialNumberPrefix | | undefined | string | charge point serial number prefix | -| chargeBoxSerialNumberPrefix | | undefined | string | charge box serial number prefix (deprecated since OCPP 1.6) | -| meterSerialNumberPrefix | | undefined | string | meter serial number prefix | -| meterType | | undefined | string | meter type | -| firmwareVersionPattern | | Semantic versioning regular expression: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string | string | charging stations firmware version pattern | -| firmwareVersion | | undefined | string | charging stations firmware version | -| power | | | float \| float[] | charging stations maximum power value(s) | -| powerSharedByConnectors | true/false | false | boolean | charging stations power shared by its connectors. When true, any single connector can draw up to the full station power; when false, each connector is allocated an equal share | -| powerUnit | W/kW | W | string | charging stations power unit | -| currentOutType | AC/DC | AC | string | charging stations current out type | -| voltageOut | | AC:230/DC:400 | integer | charging stations voltage out | -| numberOfPhases | 0/1/3 | AC:3/DC:0 | integer | charging stations number of phase(s) | -| numberOfConnectors | | | integer \| integer[] | charging stations number of connector(s) | -| useConnectorId0 | true/false | true | boolean | use connector id 0 definition from the charging station configuration template | -| randomConnectors | true/false | false | boolean | randomize runtime connector id affectation from the connector id definition in charging station configuration template | -| resetTime | | 60 | integer | seconds to wait before the charging stations come back at reset | -| autoRegister | true/false | false | boolean | set charging stations as registered at boot notification for testing purpose | -| autoReconnectMaxRetries | | -1 (unlimited) | integer | connection retries to the OCPP-J server | -| reconnectExponentialDelay | true/false | false | boolean | connection delay retry to the OCPP-J server | -| registrationMaxRetries | | -1 (unlimited) | integer | charging stations boot notification retries | -| amperageLimitationOcppKey | | undefined | string | charging stations OCPP parameter key used to set the amperage limit, per phase for each connector on AC and global for DC | -| amperageLimitationUnit | A/cA/dA/mA | A | string | charging stations amperage limit unit | -| enableStatistics | true/false | false | boolean | enable charging stations statistics | -| remoteAuthorization | true/false | true | boolean | enable RFID tags remote authorization | -| beginEndMeterValues | true/false | false | boolean | enable Transaction.{Begin,End} MeterValues | -| outOfOrderEndMeterValues | true/false | false | boolean | send Transaction.End MeterValues out of order. Need to relax OCPP specifications strict compliance ('ocppStrictCompliance' parameter) | -| meteringPerTransaction | true/false | true | boolean | enable metering history on a per transaction basis | -| transactionDataMeterValues | true/false | false | boolean | enable transaction data MeterValues at stop transaction | -| stopTransactionsOnStopped | true/false | true | boolean | enable stop transactions on charging station stop | -| postTransactionDelay | ≥ 0 | 0 | integer | seconds to wait after transaction stop before transitioning connector to Available. Simulates cable-unplug delay. In OCPP 1.6 the connector stays in Finishing state; in OCPP 2.0.x it stays Occupied. 0 = immediate Available (default behavior) | -| mainVoltageMeterValues | true/false | true | boolean | include charging stations main voltage MeterValues on three phased charging stations | -| phaseLineToLineVoltageMeterValues | true/false | false | boolean | include charging stations line to line voltage MeterValues on three phased charging stations | -| customValueLimitationMeterValues | true/false | true | boolean | enable limitation on custom fluctuated value in MeterValues | -| firmwareUpgrade | | {
"versionUpgrade": {
"step": 1
},
"reset": true
} | {
versionUpgrade?: {
patternGroup?: number;
step?: number;
};
reset?: boolean;
failureStatus?: 'DownloadFailed' \| 'InstallationFailed';
} | Configuration section for simulating firmware upgrade support. | -| commandsSupport | | {
"incomingCommands": {},
"outgoingCommands": {}
} | {
incomingCommands: Record;
outgoingCommands?: Record;
} | Configuration section for OCPP commands support. Empty section or subsections means all implemented OCPP commands are supported | -| messageTriggerSupport | | {} | Record | Configuration section for OCPP commands trigger support. Empty section means all implemented OCPP trigger commands are supported | -| Configuration | | | ChargingStationOcppConfiguration | charging stations OCPP parameters configuration section | -| AutomaticTransactionGenerator | | | AutomaticTransactionGeneratorConfiguration | charging stations ATG configuration section | -| Connectors | | | Record | charging stations connectors configuration section | -| Evses | | | Record | charging stations EVSEs configuration section | +| Key | Value(s) | Default Value | Value type | Description | +| ---------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| supervisionUrls | | [] | string \| string[] | string or strings array containing connection URIs to OCPP-J servers | +| 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 | +| 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 | +| ocppStrictCompliance | true/false | true | boolean | enable strict adherence to the OCPP version and protocol specifications with OCPP commands PDU validation against [OCA](https://www.openchargealliance.org/) JSON schemas | +| ocppPersistentConfiguration | true/false | true | boolean | enable persistent OCPP parameters storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | +| stationInfoPersistentConfiguration | true/false | true | boolean | enable persistent station information and specifications storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | +| automaticTransactionGeneratorPersistentConfiguration | true/false | true | boolean | enable persistent automatic transaction generator configuration storage by charging stations 'hashId'. The persistency is ensured by the charging stations configuration files in [dist/assets/configurations](./dist/assets/configurations) | +| wsOptions | | {} | ClientOptions & ClientRequestArgs | [ws](https://github.com/websockets/ws) and node.js [http](https://nodejs.org/api/http.html) clients options intersection | +| idTagsFile | | undefined | string | RFID tags list file relative to [src/assets](./src/assets) path | +| iccid | | undefined | string | SIM card ICCID | +| imsi | | undefined | string | SIM card IMSI | +| baseName | | undefined | string | base name to build charging stations id | +| nameSuffix | | undefined | string | name suffix to build charging stations id | +| fixedName | true/false | false | boolean | use the 'baseName' as the charging stations unique name | +| chargePointModel | | undefined | string | charging stations model | +| chargePointVendor | | undefined | string | charging stations vendor | +| chargePointSerialNumberPrefix | | undefined | string | charge point serial number prefix | +| chargeBoxSerialNumberPrefix | | undefined | string | charge box serial number prefix (deprecated since OCPP 1.6) | +| meterSerialNumberPrefix | | undefined | string | meter serial number prefix | +| meterType | | undefined | string | meter type | +| firmwareVersionPattern | | Semantic versioning regular expression: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string | string | charging stations firmware version pattern | +| firmwareVersion | | undefined | string | charging stations firmware version | +| power | | | float \| float[] | charging stations maximum power value(s) | +| powerSharedByConnectors | true/false | false | boolean | charging stations power shared by its connectors. When true, any single connector can draw up to the full station power; when false, each connector is allocated an equal share | +| powerUnit | W/kW | W | string | charging stations power unit | +| currentOutType | AC/DC | AC | string | charging stations current out type | +| voltageOut | | AC:230/DC:400 | integer | charging stations voltage out | +| numberOfPhases | 0/1/3 | AC:3/DC:0 | integer | charging stations number of phase(s) | +| numberOfConnectors | | | integer \| integer[] | charging stations number of connector(s) | +| useConnectorId0 | true/false | true | boolean | use connector id 0 definition from the charging station configuration template | +| randomConnectors | true/false | false | boolean | randomize runtime connector id affectation from the connector id definition in charging station configuration template | +| resetTime | | 60 | integer | seconds to wait before the charging stations come back at reset | +| autoRegister | true/false | false | boolean | set charging stations as registered at boot notification for testing purpose | +| autoReconnectMaxRetries | | -1 (unlimited) | integer | connection retries to the OCPP-J server | +| reconnectExponentialDelay | true/false | false | boolean | connection delay retry to the OCPP-J server | +| registrationMaxRetries | | -1 (unlimited) | integer | charging stations boot notification retries | +| amperageLimitationOcppKey | | undefined | string | charging stations OCPP parameter key used to set the amperage limit, per phase for each connector on AC and global for DC | +| amperageLimitationUnit | A/cA/dA/mA | A | string | charging stations amperage limit unit | +| enableStatistics | true/false | false | boolean | enable charging stations statistics | +| remoteAuthorization | true/false | true | boolean | enable RFID tags remote authorization | +| forceTransactionOnInvalidIdToken | true/false | false | boolean | continue station-initiated transactions when CSMS rejects the IdToken (`idTagInfo.status` ≠ Accepted in 1.6; `idTokenInfo.status` ≠ Accepted on `eventType=Started` in 2.0.1; mid-tx revocation on `Updated`/`Ended` still tears down). Non-spec-compliant when true (violates OCPP 2.0.1 E05.FR.09 / E05.FR.10 / E06.FR.04); independent of `ocppStrictCompliance`; distinct from OCPP variables `StopTransactionOnInvalidId` / `StopTxOnInvalidId` (mid-tx stop control) | +| beginEndMeterValues | true/false | false | boolean | enable Transaction.{Begin,End} MeterValues | +| outOfOrderEndMeterValues | true/false | false | boolean | send Transaction.End MeterValues out of order. Need to relax OCPP specifications strict compliance ('ocppStrictCompliance' parameter) | +| meteringPerTransaction | true/false | true | boolean | enable metering history on a per transaction basis | +| transactionDataMeterValues | true/false | false | boolean | enable transaction data MeterValues at stop transaction | +| stopTransactionsOnStopped | true/false | true | boolean | enable stop transactions on charging station stop | +| postTransactionDelay | ≥ 0 | 0 | integer | seconds to wait after transaction stop before transitioning connector to Available. Simulates cable-unplug delay. In OCPP 1.6 the connector stays in Finishing state; in OCPP 2.0.x it stays Occupied. 0 = immediate Available (default behavior) | +| mainVoltageMeterValues | true/false | true | boolean | include charging stations main voltage MeterValues on three phased charging stations | +| phaseLineToLineVoltageMeterValues | true/false | false | boolean | include charging stations line to line voltage MeterValues on three phased charging stations | +| customValueLimitationMeterValues | true/false | true | boolean | enable limitation on custom fluctuated value in MeterValues | +| firmwareUpgrade | | {
"versionUpgrade": {
"step": 1
},
"reset": true
} | {
versionUpgrade?: {
patternGroup?: number;
step?: number;
};
reset?: boolean;
failureStatus?: 'DownloadFailed' \| 'InstallationFailed';
} | Configuration section for simulating firmware upgrade support. | +| commandsSupport | | {
"incomingCommands": {},
"outgoingCommands": {}
} | {
incomingCommands: Record;
outgoingCommands?: Record;
} | Configuration section for OCPP commands support. Empty section or subsections means all implemented OCPP commands are supported | +| messageTriggerSupport | | {} | Record | Configuration section for OCPP commands trigger support. Empty section means all implemented OCPP trigger commands are supported | +| Configuration | | | ChargingStationOcppConfiguration | charging stations OCPP parameters configuration section | +| AutomaticTransactionGenerator | | | AutomaticTransactionGeneratorConfiguration | charging stations ATG configuration section | +| Connectors | | | Record | charging stations connectors configuration section | +| Evses | | | Record | charging stations EVSEs configuration section | #### Configuration section syntax example diff --git a/src/charging-station/TemplateSchema.ts b/src/charging-station/TemplateSchema.ts index ea85e50e..094336b6 100644 --- a/src/charging-station/TemplateSchema.ts +++ b/src/charging-station/TemplateSchema.ts @@ -190,6 +190,7 @@ const BaseTemplateSchema = z.looseObject({ firmwareVersion: z.string().optional(), firmwareVersionPattern: z.string().optional(), fixedName: z.boolean().optional(), + forceTransactionOnInvalidIdToken: z.boolean().optional(), iccid: z.string().optional(), idTagsFile: z.string().optional(), imsi: z.string().optional(), diff --git a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts index c046a819..e8a301d6 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts @@ -401,7 +401,30 @@ export class OCPP16ResponseService extends OCPPResponseService { payload.transactionId = convertToInt(payload.transactionId) } - if (payload.idTagInfo.status === OCPP16AuthorizationStatus.ACCEPTED) { + const idTokenAccepted = payload.idTagInfo.status === OCPP16AuthorizationStatus.ACCEPTED + const forceTransactionOnInvalidIdToken = + chargingStation.stationInfo?.forceTransactionOnInvalidIdToken === true + if (!idTokenAccepted) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.handleResponseStartTransaction: Starting transaction with id ${payload.transactionId.toString()} REJECTED on ${ + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + chargingStation.stationInfo?.chargingStationId + }#${connectorId.toString()} with status '${payload.idTagInfo.status}', idTag '${truncateId( + requestPayload.idTag + )}'${ + OCPP16ServiceUtils.hasReservation(chargingStation, connectorId, requestPayload.idTag) + ? // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `, reservationId '${requestPayload.reservationId?.toString()}'` + : '' + }` + ) + if (forceTransactionOnInvalidIdToken) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.handleResponseStartTransaction: Forcing transaction id ${payload.transactionId.toString()} on connector id ${connectorId.toString()} despite idTagInfo status '${payload.idTagInfo.status}' per forceTransactionOnInvalidIdToken=true` + ) + } + } + if (idTokenAccepted || forceTransactionOnInvalidIdToken) { connectorStatus.transactionStarted = true connectorStatus.transactionStart = requestPayload.timestamp connectorStatus.transactionId = payload.transactionId @@ -482,19 +505,6 @@ export class OCPP16ResponseService extends OCPPResponseService { : Constants.DEFAULT_METER_VALUES_INTERVAL_MS ) } else { - logger.warn( - `${chargingStation.logPrefix()} ${moduleName}.handleResponseStartTransaction: Starting transaction with id ${payload.transactionId.toString()} REJECTED on ${ - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - chargingStation.stationInfo?.chargingStationId - }#${connectorId.toString()} with status '${payload.idTagInfo.status}', idTag '${truncateId( - requestPayload.idTag - )}'${ - OCPP16ServiceUtils.hasReservation(chargingStation, connectorId, requestPayload.idTag) - ? // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `, reservationId '${requestPayload.reservationId?.toString()}'` - : '' - }` - ) await this.resetConnectorOnStartTransactionError(chargingStation, connectorId) } OCPP16ServiceUtils.updateAuthorizationCache( diff --git a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts index fb230a47..901001f1 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ResponseService.ts @@ -424,7 +424,8 @@ export class OCPP20ResponseService extends OCPPResponseService { connectorStatus.transactionEnergyActiveImportRegisterValue ??= 0 const isIdTokenAccepted = payload.idTokenInfo == null || - payload.idTokenInfo.status === OCPP20AuthorizationStatusEnumType.Accepted + payload.idTokenInfo.status === OCPP20AuthorizationStatusEnumType.Accepted || + chargingStation.stationInfo?.forceTransactionOnInvalidIdToken === true if (isIdTokenAccepted) { connectorStatus.locked = true } @@ -467,8 +468,17 @@ export class OCPP20ResponseService extends OCPPResponseService { logger.info( `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken info status: ${payload.idTokenInfo.status}` ) - // E05.FR.09/FR.10 + E06.FR.04: Deauthorize transaction when idToken is not accepted by CSMS - if (payload.idTokenInfo.status !== OCPP20AuthorizationStatusEnumType.Accepted) { + // E05.FR.09/FR.10 + E06.FR.04: Deauthorize when idToken not Accepted by CSMS. + // Override gate documented on `ChargingStationTemplate.forceTransactionOnInvalidIdToken`. + const forceTransactionOnInvalidIdToken = + chargingStation.stationInfo?.forceTransactionOnInvalidIdToken === true + const overrideRejection = + forceTransactionOnInvalidIdToken && + requestPayload.eventType === OCPP20TransactionEventEnumType.Started + if ( + payload.idTokenInfo.status !== OCPP20AuthorizationStatusEnumType.Accepted && + !overrideRejection + ) { logger.warn( `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken authorization rejected with status '${payload.idTokenInfo.status}', de-authorizing transaction per E05.FR.09/E05.FR.10/E06.FR.04` ) @@ -494,6 +504,10 @@ export class OCPP20ResponseService extends OCPPResponseService { `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Could not find connector for transaction ${requestPayload.transactionInfo.transactionId}, cannot de-authorize` ) } + } else if (overrideRejection) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Forcing transaction ${requestPayload.transactionInfo.transactionId} on eventType=Started despite idTokenInfo status '${payload.idTokenInfo.status}' per forceTransactionOnInvalidIdToken=true` + ) } // C10.FR.01/04/05: Update auth cache with idTokenInfo from response if (requestPayload.idToken != null) { diff --git a/src/types/ChargingStationTemplate.ts b/src/types/ChargingStationTemplate.ts index f88f24da..10a34e4f 100644 --- a/src/types/ChargingStationTemplate.ts +++ b/src/types/ChargingStationTemplate.ts @@ -73,6 +73,17 @@ export interface ChargingStationTemplate { firmwareVersion?: string firmwareVersionPattern?: string fixedName?: boolean + /** + * Continue station-initiated transactions when CSMS rejects the IdToken + * (`idTagInfo.status` != Accepted in 1.6; `idTokenInfo.status` != Accepted + * on `eventType=Started` in 2.0.1; mid-tx revocation on `Updated`/`Ended` + * still tears down). Default `false`; when `true`, violates OCPP 2.0.1 + * E05.FR.09 / E05.FR.10 / E06.FR.04. Independent of `ocppStrictCompliance` + * (operates on response handling, not schema validation). Distinct from + * OCPP variables `StopTransactionOnInvalidId` / `StopTxOnInvalidId` + * (mid-tx stop control); this flag overrides the start-time gate only. + */ + forceTransactionOnInvalidIdToken?: boolean iccid?: string idTagsFile?: string imsi?: string diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index 65fcfad6..a0622fb4 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -73,6 +73,7 @@ export class Constants { // See https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string firmwareVersionPattern: '^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$', + forceTransactionOnInvalidIdToken: false, mainVoltageMeterValues: true, meteringPerTransaction: true, ocppPersistentConfiguration: true, diff --git a/tests/charging-station/ocpp/1.6/OCPP16ResponseService-ForceTxOnInvalid.test.ts b/tests/charging-station/ocpp/1.6/OCPP16ResponseService-ForceTxOnInvalid.test.ts new file mode 100644 index 00000000..d6895c2b --- /dev/null +++ b/tests/charging-station/ocpp/1.6/OCPP16ResponseService-ForceTxOnInvalid.test.ts @@ -0,0 +1,283 @@ +/** + * @file Tests for OCPP16ResponseService — `forceTransactionOnInvalidIdToken` + * template flag (issue #1826). + * @description Asserts that, when the station-template flag + * `forceTransactionOnInvalidIdToken` is `true`, a StartTransaction response + * carrying `idTagInfo.status === Invalid` does NOT abort: the connector + * adopts the transaction (transactionStarted, transactionId, transactionIdTag, + * locked) as if Accepted, the MeterValues sample timer is initialized, and a + * warn-log entry containing the literal `forceTransactionOnInvalidIdToken=true` + * is emitted. The authorization cache update is NOT relaxed (it always + * reflects what CSMS replied). Pre-Start local-state guards (e.g. + * remote-start with un-authorized idTag) are NOT relaxed by the flag. + * + * Default-off regression bound is covered by the sibling test in + * `OCPP16ResponseService-Transactions.test.ts:212` and is intentionally not + * duplicated here. + * + * Test runner: node:test (`pnpm test`). No Jest, no Vitest. + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/index.js' +import type { OCPP16ResponseService } from '../../../../src/charging-station/ocpp/1.6/OCPP16ResponseService.js' +import type { + OCPP16StartTransactionRequest, + OCPP16StartTransactionResponse, +} from '../../../../src/types/index.js' + +import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js' +import { + OCPP16AuthorizationStatus, + OCPP16MeterValueUnit, + OCPP16RequestCommand, +} from '../../../../src/types/index.js' +import { logger } from '../../../../src/utils/index.js' +import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' +import { TEST_ID_TAG } from '../../ChargingStationTestConstants.js' +import { createOCPP16ResponseTestContext, setMockRequestHandler } from './OCPP16TestUtils.js' + +await describe('OCPP16ResponseService — forceTransactionOnInvalidIdToken (issue #1826)', async () => { + let station: ChargingStation + let responseService: OCPP16ResponseService + + beforeEach(() => { + const ctx = createOCPP16ResponseTestContext({ + stationInfo: { forceTransactionOnInvalidIdToken: true }, + }) + station = ctx.station + responseService = ctx.responseService + setMockRequestHandler(station, async () => Promise.resolve({})) + mock.method(OCPP16ServiceUtils, 'startUpdatedMeterValues', () => { + /* noop */ + }) + mock.method(OCPP16ServiceUtils, 'stopUpdatedMeterValues', () => { + /* noop */ + }) + for (const { connectorId } of station.iterateConnectors(true)) { + const connectorStatus = station.getConnectorStatus(connectorId) + if (connectorStatus != null) { + connectorStatus.MeterValues = [{ unit: OCPP16MeterValueUnit.WATT_HOUR, value: '0' }] + } + } + }) + + afterEach(() => { + standardCleanup() + }) + + // 1.6-T2 — Force-tx on Invalid: transaction continues as if Accepted. + // TODO Phase 6 (golden set): add a fake-timer fence to verify the MV pump + // actually emits a MeterValues request over the wire — the helper-call + // assertion below stubs the pump and therefore does not catch a wire-level + // regression where startUpdatedMeterValues is invoked but the interval is + // bound to the wrong connector. Phase 6 closes this gap. + await it('should continue the transaction when CSMS replies Invalid and the flag is true', async () => { + // Arrange + const connectorId = 1 + const transactionId = 4242 + const startUpdatedMeterValuesMock = mock.method( + OCPP16ServiceUtils, + 'startUpdatedMeterValues', + () => { + /* noop */ + } + ) + const requestPayload: OCPP16StartTransactionRequest = { + connectorId, + idTag: TEST_ID_TAG, + meterStart: 0, + timestamp: new Date('2025-01-01T12:00:00Z'), + } + const responsePayload: OCPP16StartTransactionResponse = { + idTagInfo: { status: OCPP16AuthorizationStatus.INVALID }, + transactionId, + } + + // Act — public dispatcher routes to private handleResponseStartTransaction. + await responseService.responseHandler( + station, + OCPP16RequestCommand.START_TRANSACTION, + responsePayload, + requestPayload + ) + + // Assert — connector adopted the transaction. + const connectorStatus = station.getConnectorStatus(connectorId) + if (connectorStatus == null) { + assert.fail('Expected connector to be defined') + } + assert.strictEqual(connectorStatus.transactionStarted, true) + assert.strictEqual(connectorStatus.transactionId, transactionId) + assert.strictEqual(connectorStatus.transactionIdTag, TEST_ID_TAG) + assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 0) + assert.strictEqual(connectorStatus.locked, true) + assert.deepStrictEqual(connectorStatus.transactionStart, requestPayload.timestamp) + // MV pump initialized so the simulator actually meters the override session. + assert.strictEqual(startUpdatedMeterValuesMock.mock.calls.length, 1) + }) + + // 1.6-T3 — Override marker present in warn-level log. + await it('should emit a warn log line containing the override marker', async () => { + // Arrange + const warnMock = mock.method(logger, 'warn', () => undefined) + const connectorId = 1 + const requestPayload: OCPP16StartTransactionRequest = { + connectorId, + idTag: TEST_ID_TAG, + meterStart: 0, + timestamp: new Date(), + } + const responsePayload: OCPP16StartTransactionResponse = { + idTagInfo: { status: OCPP16AuthorizationStatus.INVALID }, + transactionId: 7, + } + + // Act + await responseService.responseHandler( + station, + OCPP16RequestCommand.START_TRANSACTION, + responsePayload, + requestPayload + ) + + // Assert — at least one warn call carries the literal override marker. + const overrideMarkerCalls = warnMock.mock.calls.filter(call => { + const firstArg: unknown = call.arguments[0] + return ( + typeof firstArg === 'string' && firstArg.includes('forceTransactionOnInvalidIdToken=true') + ) + }) + assert.strictEqual(overrideMarkerCalls.length, 1) + }) + + // 1.6-T4 — Authorization cache update is NOT relaxed by the flag. + await it('should still update the authorization cache with the CSMS-supplied idTagInfo', async () => { + // Arrange + const updateAuthMock = mock.method( + OCPP16ServiceUtils, + 'updateAuthorizationCache', + () => undefined + ) + const connectorId = 1 + const requestPayload: OCPP16StartTransactionRequest = { + connectorId, + idTag: TEST_ID_TAG, + meterStart: 0, + timestamp: new Date(), + } + const responsePayload: OCPP16StartTransactionResponse = { + idTagInfo: { status: OCPP16AuthorizationStatus.INVALID }, + transactionId: 11, + } + + // Act + await responseService.responseHandler( + station, + OCPP16RequestCommand.START_TRANSACTION, + responsePayload, + requestPayload + ) + + // Assert — exactly one cache update with the Invalid idTagInfo. + assert.strictEqual(updateAuthMock.mock.calls.length, 1) + assert.strictEqual(updateAuthMock.mock.calls[0].arguments[1], TEST_ID_TAG) + assert.deepStrictEqual(updateAuthMock.mock.calls[0].arguments[2], { + status: OCPP16AuthorizationStatus.INVALID, + }) + }) + + // 1.6-T5 — Pre-Start local-state guards are NOT relaxed. + // The remote-start guard at :315-329 must still abort even when the flag is true. + await it('should still abort on the pre-Start unauthorized-remote-start guard regardless of the flag', async () => { + // Arrange — drive the guard at OCPP16ResponseService.ts:315-329: + // transactionRemoteStarted=true, AuthorizeRemoteTxRequests=true, + // remoteAuthorization=true, idTagAuthorized=false, idTagLocalAuthorized=false. + const connectorId = 1 + const connectorStatus = station.getConnectorStatus(connectorId) + if (connectorStatus == null) { + assert.fail('Expected connector to be defined') + } + connectorStatus.transactionRemoteStarted = true + connectorStatus.idTagAuthorized = false + connectorStatus.idTagLocalAuthorized = false + connectorStatus.authorizeIdTag = TEST_ID_TAG + ;( + station as unknown as { getAuthorizeRemoteTxRequests: () => boolean } + ).getAuthorizeRemoteTxRequests = () => true + const stationInfo = station.stationInfo + if (stationInfo != null) { + stationInfo.remoteAuthorization = true + } + + const requestPayload: OCPP16StartTransactionRequest = { + connectorId, + idTag: TEST_ID_TAG, + meterStart: 0, + timestamp: new Date(), + } + const responsePayload: OCPP16StartTransactionResponse = { + // INVALID + flag=true exercises the regression: without the pre-Start + // guard the override would skip the abort path. The guard MUST still + // win. ACCEPTED would not exercise the flag-vs-guard interaction. + idTagInfo: { status: OCPP16AuthorizationStatus.INVALID }, + transactionId: 22, + } + + // Act + await responseService.responseHandler( + station, + OCPP16RequestCommand.START_TRANSACTION, + responsePayload, + requestPayload + ) + + // Assert — guard fires: connector reset, no transaction adopted. + assert.strictEqual(connectorStatus.transactionStarted, false) + assert.strictEqual(connectorStatus.transactionId, undefined) + assert.strictEqual(connectorStatus.transactionIdTag, undefined) + }) + + // 1.6-T6 — Status-enum parity: every non-Accepted, non-ConcurrentTx status + // follows the override path on StartTransaction when the flag is true. + // ConcurrentTx is excluded because OCPP 1.6 routes it through a different + // code path (concurrent transaction detection), outside this issue's scope. + for (const status of Object.values(OCPP16AuthorizationStatus).filter( + s => s !== OCPP16AuthorizationStatus.ACCEPTED && s !== OCPP16AuthorizationStatus.CONCURRENT_TX + )) { + await it(`should continue the transaction for status=${status} when the flag is true`, async () => { + const startUpdatedMeterValuesMock = mock.method( + OCPP16ServiceUtils, + 'startUpdatedMeterValues', + () => undefined + ) + const connectorId = 1 + const requestPayload: OCPP16StartTransactionRequest = { + connectorId, + idTag: TEST_ID_TAG, + meterStart: 0, + timestamp: new Date(), + } + const responsePayload: OCPP16StartTransactionResponse = { + idTagInfo: { status }, + transactionId: 4242, + } + + await responseService.responseHandler( + station, + OCPP16RequestCommand.START_TRANSACTION, + responsePayload, + requestPayload + ) + + const connectorStatus = station.getConnectorStatus(connectorId) + if (connectorStatus == null) { + assert.fail('Expected connector to be defined') + } + assert.strictEqual(connectorStatus.transactionStarted, true) + assert.strictEqual(startUpdatedMeterValuesMock.mock.calls.length, 1) + }) + } +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ResponseService-ForceTxOnInvalid.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-ForceTxOnInvalid.test.ts new file mode 100644 index 00000000..f65c35e2 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-ForceTxOnInvalid.test.ts @@ -0,0 +1,472 @@ +/** + * @file Tests for OCPP20ResponseService — `forceTransactionOnInvalidIdToken` + * template flag (issue #1826). + * @description Asserts that, when the station-template flag + * `forceTransactionOnInvalidIdToken` is `true`, an OCPP 2.0.1 + * `TransactionEvent(Started)` response carrying a non-Accepted + * `idTokenInfo.status` does NOT abort: `requestDeauthorizeTransaction` is + * not called, the connector is locked, the MeterValues update/ended pumps + * are started, and a warn-level log line containing the literal + * `forceTransactionOnInvalidIdToken=true` is emitted. Mid-transaction + * revocation (`Updated` / `Ended` event types with non-Accepted status) + * STILL aborts via `requestDeauthorizeTransaction` regardless of the flag, + * preserving OCPP 2.0.1 E05.FR.09 / E05.FR.10 / E06.FR.04 mid-tx semantics. + * + * Default-off regression bound is covered by the sibling tests in + * `OCPP20ResponseService-TransactionEvent.test.ts` and not duplicated here. + * + * Test runner: node:test (`pnpm test`). No Jest, no Vitest. + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it, mock } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/index.js' +import type { + OCPP20TransactionEventRequest, + OCPP20TransactionEventResponse, + UUIDv4, +} from '../../../../src/types/index.js' + +import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js' +import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' +import { + OCPP20AuthorizationStatusEnumType, + OCPP20IdTokenEnumType, + OCPP20TransactionEventEnumType, + OCPP20TriggerReasonEnumType, + OCPPVersion, +} from '../../../../src/types/index.js' +import { Constants, logger } from '../../../../src/utils/index.js' +import { + setupConnectorWithTransaction, + standardCleanup, +} from '../../../helpers/TestLifecycleHelpers.js' +import { + TEST_CHARGING_STATION_BASE_NAME, + TEST_ID_TAG, + TEST_TRANSACTION_UUID, +} from '../../ChargingStationTestConstants.js' +import { createMockChargingStation } from '../../helpers/StationHelpers.js' + +interface TestableOCPP20ResponseService { + handleResponseTransactionEvent: ( + chargingStation: ChargingStation, + payload: OCPP20TransactionEventResponse, + requestPayload: OCPP20TransactionEventRequest + ) => Promise +} + +/** + * Builds a minimal OCPP20TransactionEventRequest with the given event type and + * transaction id. Used as the request-payload twin in handler dispatch. + * @param transactionId - The transaction UUID embedded in transactionInfo + * @param eventType - The TransactionEvent type (Started/Updated/Ended) + * @param idToken - Optional idToken to attach; required to exercise the auth-cache + * update path at OCPP20ResponseService.ts (C10.FR.01/04/05) + * @param idToken.idToken + * @param idToken.type + * @returns A minimal OCPP20TransactionEventRequest + */ +function buildTransactionEventRequest ( + transactionId: UUIDv4, + eventType: OCPP20TransactionEventEnumType, + idToken?: { idToken: string; type: OCPP20IdTokenEnumType } +): OCPP20TransactionEventRequest { + return { + eventType, + ...(idToken != null ? { idToken } : {}), + meterValue: [], + seqNo: 0, + timestamp: new Date(), + transactionInfo: { + transactionId, + }, + triggerReason: OCPP20TriggerReasonEnumType.Authorized, + } +} + +/** + * Wraps an OCPP20ResponseService instance so its private + * `handleResponseTransactionEvent` is reachable by tests via a typed cast. + * Mirrors the helper in `OCPP20ResponseService-TransactionEvent.test.ts`. + * @param service - The OCPP20ResponseService instance to wrap + * @returns A typed interface exposing the private handler + */ +function createTestableResponseService ( + service: OCPP20ResponseService +): TestableOCPP20ResponseService { + const serviceImpl = service as unknown as TestableOCPP20ResponseService + return { + handleResponseTransactionEvent: serviceImpl.handleResponseTransactionEvent.bind(service), + } +} + +await describe('OCPP20ResponseService — forceTransactionOnInvalidIdToken (issue #1826)', async () => { + let station: ChargingStation + let testable: TestableOCPP20ResponseService + + beforeEach(() => { + const { station: mockStation } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + evseConfiguration: { evsesCount: 1 }, + stationInfo: { + forceTransactionOnInvalidIdToken: true, + ocppStrictCompliance: false, + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + station = mockStation + setupConnectorWithTransaction(station, 1, { transactionId: 100 }) + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.transactionId = TEST_TRANSACTION_UUID + } + const responseService = new OCPP20ResponseService() + testable = createTestableResponseService(responseService) + }) + + afterEach(() => { + standardCleanup() + }) + + // 2.0-T2 — Force-tx on Invalid `Started`: deauth NOT called, connector + // locked, MV update + ended pumps started. + // TODO Phase 6 (golden set): add a fake-timer fence to verify the MV pump + // actually emits a TransactionEvent(Updated) over the wire — the helper- + // call assertion below stubs the pump and therefore does not catch a + // wire-level regression where startUpdatedMeterValues is invoked but the + // interval is bound to the wrong connector. Phase 6 closes this gap. + await it('should not deauthorize on Invalid Started when the flag is true', async () => { + // Arrange + const mockDeauthTransaction = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + async () => Promise.resolve({} as OCPP20TransactionEventResponse) + ) + const mockStartUpdated = mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => { + /* noop */ + }) + const mockStartEnded = mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => { + /* noop */ + }) + const payload: OCPP20TransactionEventResponse = { + idTokenInfo: { + status: OCPP20AuthorizationStatusEnumType.Invalid, + }, + } + const requestPayload = buildTransactionEventRequest( + TEST_TRANSACTION_UUID, + OCPP20TransactionEventEnumType.Started + ) + + // Act + await testable.handleResponseTransactionEvent(station, payload, requestPayload) + + // Assert — deauth NOT called; locked + MV pumps started. + assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0) + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus == null) { + assert.fail('Expected connector to be defined') + } + assert.strictEqual(connectorStatus.locked, true) + assert.strictEqual(mockStartUpdated.mock.calls.length, 1) + assert.strictEqual(mockStartEnded.mock.calls.length, 1) + }) + + // 2.0-T3 — Mid-transaction revocation (Updated) STILL aborts. + await it('should still de-authorize on Invalid Updated when the flag is true', async () => { + // Arrange + const mockDeauthTransaction = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + async () => Promise.resolve({} as OCPP20TransactionEventResponse) + ) + const payload: OCPP20TransactionEventResponse = { + idTokenInfo: { + status: OCPP20AuthorizationStatusEnumType.Invalid, + }, + } + const requestPayload = buildTransactionEventRequest( + TEST_TRANSACTION_UUID, + OCPP20TransactionEventEnumType.Updated + ) + + // Act + await testable.handleResponseTransactionEvent(station, payload, requestPayload) + + // Assert + assert.strictEqual(mockDeauthTransaction.mock.calls.length, 1) + }) + + // 2.0-T4 — Mid-transaction revocation (Ended) STILL tears down (regardless of flag). + await it('should clean up on Invalid Ended even when the flag is true (mid-tx tear-down preserved)', async () => { + // Arrange — In OCPP 2.0.1, the Ended branch runs cleanupEndedTransaction BEFORE the + // deauth gate. With the flag on, we cannot bypass mid-transaction tear-down: the + // case Ended in the switch always cleans up. Asserting on connector cleanup is the + // accurate way to pin the "mid-tx revocation always aborts" invariant for Ended. + const { station: endedStation } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + evseConfiguration: { evsesCount: 1 }, + ocppRequestService: { + requestHandler: async () => Promise.resolve({}), + }, + stationInfo: { + forceTransactionOnInvalidIdToken: true, + ocppStrictCompliance: false, + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + setupConnectorWithTransaction(endedStation, 1, { transactionId: 100 }) + const endedConnector = endedStation.getConnectorStatus(1) + if (endedConnector != null) { + endedConnector.transactionId = TEST_TRANSACTION_UUID + } + const endedTestable = createTestableResponseService(new OCPP20ResponseService()) + const mockDeauthEnded = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + async () => Promise.resolve({} as OCPP20TransactionEventResponse) + ) + const payload: OCPP20TransactionEventResponse = { + idTokenInfo: { + status: OCPP20AuthorizationStatusEnumType.Invalid, + }, + } + const requestPayload = buildTransactionEventRequest( + TEST_TRANSACTION_UUID, + OCPP20TransactionEventEnumType.Ended + ) + + // Act + await endedTestable.handleResponseTransactionEvent(endedStation, payload, requestPayload) + + // Asserts cleanup ran AND deauth was a no-op (cleanupEndedTransaction + // clears transactionId before the gate, so the connector lookup fails). + // `=== 0` locks the cleanup-then-gate ordering: any regression that + // reorders or preserves transactionId on Ended flips this to 1. + if (endedConnector == null) { + assert.fail('endedConnector should be defined after setupConnectorWithTransaction') + } + assert.strictEqual(endedConnector.transactionStarted, false) + assert.strictEqual(endedConnector.locked, false) + assert.strictEqual(mockDeauthEnded.mock.calls.length, 0) + }) + + // 2.0-T5 — Override marker present in warn-level log on Started override path. + await it('should emit a warn log line containing the override marker', async () => { + // Arrange + mock.method(OCPP20ServiceUtils, 'requestDeauthorizeTransaction', async () => + Promise.resolve({} as OCPP20TransactionEventResponse) + ) + mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => { + /* noop */ + }) + mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => { + /* noop */ + }) + const warnMock = mock.method(logger, 'warn', () => undefined) + const payload: OCPP20TransactionEventResponse = { + idTokenInfo: { + status: OCPP20AuthorizationStatusEnumType.Invalid, + }, + } + const requestPayload = buildTransactionEventRequest( + TEST_TRANSACTION_UUID, + OCPP20TransactionEventEnumType.Started + ) + + // Act + await testable.handleResponseTransactionEvent(station, payload, requestPayload) + + // Assert + const overrideMarkerCalls = warnMock.mock.calls.filter(call => { + const firstArg: unknown = call.arguments[0] + return ( + typeof firstArg === 'string' && firstArg.includes('forceTransactionOnInvalidIdToken=true') + ) + }) + assert.strictEqual(overrideMarkerCalls.length, 1) + }) + + // 2.0-T6 — `idTokenInfo == null` is treated as Accepted under both flag values. + // Split into two `it()` blocks (flag-on / flag-off) so each runs against a + // freshly-mocked station; avoids the mid-test cleanup+remock pattern. + // Both branches additionally assert that the override-marker warn-log is + // NOT emitted on null payload (locks the invariant against the A6 regression + // where someone "fixes" the override-marker `else if` to also fire on null). + await it('should treat null idTokenInfo as Accepted when the flag is true', async () => { + const mockDeauthOn = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + async () => Promise.resolve({} as OCPP20TransactionEventResponse) + ) + mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined) + mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined) + const warnMockOn = mock.method(logger, 'warn', () => undefined) + + const payload: OCPP20TransactionEventResponse = {} + const requestPayload = buildTransactionEventRequest( + TEST_TRANSACTION_UUID, + OCPP20TransactionEventEnumType.Started + ) + + await testable.handleResponseTransactionEvent(station, payload, requestPayload) + + assert.strictEqual(mockDeauthOn.mock.calls.length, 0) + const overrideMarkerCallsOn = warnMockOn.mock.calls.filter(call => { + const firstArg: unknown = call.arguments[0] + return ( + typeof firstArg === 'string' && firstArg.includes('forceTransactionOnInvalidIdToken=true') + ) + }) + assert.strictEqual(overrideMarkerCallsOn.length, 0) + }) + + await it('should treat null idTokenInfo as Accepted when the flag is false', async () => { + const { station: flagOffStation } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + evseConfiguration: { evsesCount: 1 }, + stationInfo: { + forceTransactionOnInvalidIdToken: false, + ocppStrictCompliance: false, + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + setupConnectorWithTransaction(flagOffStation, 1, { transactionId: 100 }) + const flagOffConnector = flagOffStation.getConnectorStatus(1) + if (flagOffConnector != null) { + flagOffConnector.transactionId = TEST_TRANSACTION_UUID + } + const mockDeauthOff = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + async () => Promise.resolve({} as OCPP20TransactionEventResponse) + ) + mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined) + mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined) + const warnMockOff = mock.method(logger, 'warn', () => undefined) + const flagOffTestable = createTestableResponseService(new OCPP20ResponseService()) + + const payload: OCPP20TransactionEventResponse = {} + const requestPayload = buildTransactionEventRequest( + TEST_TRANSACTION_UUID, + OCPP20TransactionEventEnumType.Started + ) + + await flagOffTestable.handleResponseTransactionEvent(flagOffStation, payload, requestPayload) + + assert.strictEqual(mockDeauthOff.mock.calls.length, 0) + const overrideMarkerCallsOff = warnMockOff.mock.calls.filter(call => { + const firstArg: unknown = call.arguments[0] + return ( + typeof firstArg === 'string' && firstArg.includes('forceTransactionOnInvalidIdToken=true') + ) + }) + assert.strictEqual(overrideMarkerCallsOff.length, 0) + }) + + // 2.0-T7 — Status-enum parity: every non-Accepted status follows the override + // path on Started when the flag is true (deauth NOT called, MV pumps run). + // ConcurrentTx is omitted: per OCPP 2.0.1 it is not a rejection of the IdToken + // itself but a signal that another transaction is already running, handled in + // a different code path that is outside this issue's scope. + // `Object.values(enum)` derivation makes future enum additions auto-covered. + for (const status of Object.values(OCPP20AuthorizationStatusEnumType).filter( + s => + s !== OCPP20AuthorizationStatusEnumType.Accepted && + s !== OCPP20AuthorizationStatusEnumType.ConcurrentTx + )) { + await it(`should override Started for status=${status} when the flag is true`, async () => { + // Arrange + const mockDeauthTransaction = mock.method( + OCPP20ServiceUtils, + 'requestDeauthorizeTransaction', + async () => Promise.resolve({} as OCPP20TransactionEventResponse) + ) + const mockStartUpdated = mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => { + /* noop */ + }) + const mockStartEnded = mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => { + /* noop */ + }) + const payload: OCPP20TransactionEventResponse = { + idTokenInfo: { + status, + }, + } + const requestPayload = buildTransactionEventRequest( + TEST_TRANSACTION_UUID, + OCPP20TransactionEventEnumType.Started + ) + + // Act + await testable.handleResponseTransactionEvent(station, payload, requestPayload) + + // Assert + assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0) + assert.strictEqual(mockStartUpdated.mock.calls.length, 1) + assert.strictEqual(mockStartEnded.mock.calls.length, 1) + }) + } + + // 2.0-T8 — Auth-cache update invariant (C10.FR.01/04/05): the cache is + // written with the CSMS-supplied idTokenInfo regardless of whether the + // override path was taken. Mocks `OCPP20ServiceUtils.updateAuthorizationCache` + // and asserts call-count = 1 with the right idToken + idTokenInfo for both + // flag states. + for (const flagState of [true, false] as const) { + await it(`should update the authorization cache regardless of the flag (flag=${String(flagState)})`, async () => { + const { station: cacheStation } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + evseConfiguration: { evsesCount: 1 }, + stationInfo: { + forceTransactionOnInvalidIdToken: flagState, + ocppStrictCompliance: false, + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + setupConnectorWithTransaction(cacheStation, 1, { transactionId: 100 }) + const cacheConnector = cacheStation.getConnectorStatus(1) + if (cacheConnector != null) { + cacheConnector.transactionId = TEST_TRANSACTION_UUID + } + const cacheTestable = createTestableResponseService(new OCPP20ResponseService()) + mock.method(OCPP20ServiceUtils, 'requestDeauthorizeTransaction', async () => + Promise.resolve({} as OCPP20TransactionEventResponse) + ) + mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined) + mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined) + const updateAuthMock = mock.method( + OCPP20ServiceUtils, + 'updateAuthorizationCache', + () => undefined + ) + const idToken = { idToken: TEST_ID_TAG, type: OCPP20IdTokenEnumType.ISO14443 } + const payload: OCPP20TransactionEventResponse = { + idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Invalid }, + } + const requestPayload = buildTransactionEventRequest( + TEST_TRANSACTION_UUID, + OCPP20TransactionEventEnumType.Started, + idToken + ) + + await cacheTestable.handleResponseTransactionEvent(cacheStation, payload, requestPayload) + + assert.strictEqual(updateAuthMock.mock.calls.length, 1) + assert.deepStrictEqual(updateAuthMock.mock.calls[0].arguments[1], idToken) + assert.deepStrictEqual(updateAuthMock.mock.calls[0].arguments[2], { + status: OCPP20AuthorizationStatusEnumType.Invalid, + }) + }) + } +}) -- 2.53.0