* 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.
**src/assets/station-templates/\<name\>.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 | | {<br />"versionUpgrade": {<br />"step": 1<br />},<br />"reset": true<br />} | {<br />versionUpgrade?: {<br />patternGroup?: number;<br />step?: number;<br />};<br />reset?: boolean;<br />failureStatus?: 'DownloadFailed' \| 'InstallationFailed';<br />} | Configuration section for simulating firmware upgrade support. |
-| commandsSupport | | {<br />"incomingCommands": {},<br />"outgoingCommands": {}<br />} | {<br /> incomingCommands: Record<IncomingRequestCommand, boolean>;<br />outgoingCommands?: Record<RequestCommand, boolean>;<br />} | Configuration section for OCPP commands support. Empty section or subsections means all implemented OCPP commands are supported |
-| messageTriggerSupport | | {} | Record<MessageTrigger, boolean> | 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<string, ConnectorStatus> | charging stations connectors configuration section |
-| Evses | | | Record<string, EvseTemplate> | 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 | | {<br />"versionUpgrade": {<br />"step": 1<br />},<br />"reset": true<br />} | {<br />versionUpgrade?: {<br />patternGroup?: number;<br />step?: number;<br />};<br />reset?: boolean;<br />failureStatus?: 'DownloadFailed' \| 'InstallationFailed';<br />} | Configuration section for simulating firmware upgrade support. |
+| commandsSupport | | {<br />"incomingCommands": {},<br />"outgoingCommands": {}<br />} | {<br /> incomingCommands: Record<IncomingRequestCommand, boolean>;<br />outgoingCommands?: Record<RequestCommand, boolean>;<br />} | Configuration section for OCPP commands support. Empty section or subsections means all implemented OCPP commands are supported |
+| messageTriggerSupport | | {} | Record<MessageTrigger, boolean> | 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<string, ConnectorStatus> | charging stations connectors configuration section |
+| Evses | | | Record<string, EvseTemplate> | charging stations EVSEs configuration section |
#### Configuration section syntax example
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(),
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
: 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(
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
}
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`
)
`${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) {
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
// 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,
--- /dev/null
+/**
+ * @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)
+ })
+ }
+})
--- /dev/null
+/**
+ * @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<void>
+}
+
+/**
+ * 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,
+ })
+ })
+ }
+})