]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ocpp2): fix authorization conformance gaps (C10, C12, C01, C09) (#1735)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Wed, 18 Mar 2026 10:44:58 +0000 (11:44 +0100)
committerGitHub <noreply@github.com>
Wed, 18 Mar 2026 10:44:58 +0000 (11:44 +0100)
* feat(ocpp2): auto-update auth cache on TransactionEventResponse (C10.FR.01/04/05, C12.FR.06)

* feat(ocpp2): check MasterPassGroupId in start transaction (C12.FR.09)

* feat(ocpp2): add groupId-based stop transaction authorization (C01.FR.03, C09.FR.03/07)

* fix(ocpp2): address PR review findings

- C12.FR.09: compare groupIdToken not idToken
- C09.FR.03: remove inner isIdTokenAuthorized check
- Guard updateCacheEntry on authorizationCacheEnabled
- Fix MasterPass test to mock VariableManager

* style(tests): align test naming with style guide

* fix: merge duplicate AuthTypes.js imports in auth barrel

* fix(tests): add required JSDoc to GroupIdStop test helper

* style(ocpp2): harmonize log levels, remove spec refs from log messages, use truncateId

* style(auth): harmonize log prefixes, levels, and identifier truncation

* refactor(auth): use moduleName constant for log prefixes in OCPPAuthServiceImpl

* refactor(auth): use moduleName constant across all auth files

* fix(tests): skip flaky RequestStopTransaction test on all Node 22 platforms

* refactor(auth): harmonize log prefixes in auth adapters

* refactor: move truncateId to utils and truncate identifiers in OCPP20 logs

* style(ocpp2): remove spec ref from statusInfo additionalInfo

* style(ocpp2): truncate idToken in statusInfo additionalInfo

* test(ocpp2): improve test coverage and remove duplicates in auth conformance tests

25 files changed:
src/charging-station/Helpers.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20ResponseService.ts
src/charging-station/ocpp/2.0/__testable__/index.ts
src/charging-station/ocpp/auth/adapters/OCPP16AuthAdapter.ts
src/charging-station/ocpp/auth/adapters/OCPP20AuthAdapter.ts
src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts
src/charging-station/ocpp/auth/index.ts
src/charging-station/ocpp/auth/interfaces/OCPPAuthService.ts
src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.ts
src/charging-station/ocpp/auth/strategies/CertificateAuthStrategy.ts
src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.ts
src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts
src/charging-station/ocpp/auth/types/AuthTypes.ts
src/charging-station/ocpp/auth/utils/ConfigValidator.ts
src/types/ConnectorStatus.ts
src/utils/Utils.ts
src/utils/index.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GroupIdStop.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-MasterPass.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts
tests/charging-station/ocpp/2.0/OCPP20ResponseService-CacheUpdate.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts
tests/charging-station/ocpp/auth/cache/InMemoryAuthCache.test.ts
tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy-DisablePostAuthorize.test.ts [new file with mode: 0644]

index 761d976a08af8c508a0643e5ba85354c1590b467..e86e738b163f06adbdd90f2ea37339cbbbcf95f8 100644 (file)
@@ -530,6 +530,7 @@ export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefine
   delete connectorStatus.transactionStart
   delete connectorStatus.transactionId
   delete connectorStatus.transactionIdTag
+  delete connectorStatus.transactionGroupIdToken
   connectorStatus.transactionEnergyActiveImportRegisterValue = 0
   delete connectorStatus.transactionBeginMeterValue
   delete connectorStatus.transactionSeqNo
index a06a56d0a88916c7f4a7787a8cb58dd8feb6dbc4..00c7ee1e5b0925a0ba4ec81aab001e2d27af5571 100644 (file)
@@ -133,6 +133,7 @@ import {
   generateUUID,
   logger,
   sleep,
+  truncateId,
   validateUUID,
 } from '../../../utils/index.js'
 import { getConfigurationKey } from '../../ConfigurationKeyUtils.js'
@@ -704,7 +705,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       const config = authService.getConfiguration()
       if (!config.authorizationCacheEnabled) {
         logger.info(
-          `${chargingStation.logPrefix()} ${moduleName}.handleRequestClearCache: Authorization cache disabled, returning Rejected (C11.FR.04)`
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestClearCache: Authorization cache disabled, returning Rejected`
         )
         return OCPP20Constants.OCPP_RESPONSE_REJECTED
       }
@@ -2277,7 +2278,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
   ): Promise<OCPP20RequestStartTransactionResponse> {
     const { chargingProfile, evseId, groupIdToken, idToken, remoteStartId } = commandPayload
     logger.info(
-      `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Remote start transaction request received on EVSE ${evseId?.toString() ?? 'undefined'} with idToken ${idToken.idToken} and remoteStartId ${remoteStartId.toString()}`
+      `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Remote start transaction request received on EVSE ${evseId?.toString() ?? 'undefined'} with idToken ${truncateId(idToken.idToken)} and remoteStartId ${remoteStartId.toString()}`
     )
 
     let resolvedEvseId = evseId
@@ -2357,11 +2358,38 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
     let isAuthorized = true
     if (shouldAuthorizeRemoteStart) {
+      // C12.FR.09: Check MasterPassGroupId before authorization
+      const masterPassGroupIdResults = variableManager.getVariables(chargingStation, [
+        {
+          attributeType: AttributeEnumType.Actual,
+          component: { name: OCPP20ComponentName.AuthCtrlr },
+          variable: { name: 'MasterPassGroupId' },
+        },
+      ])
+      const masterPassGroupId = masterPassGroupIdResults[0]?.attributeValue
+      if (
+        masterPassGroupId != null &&
+        masterPassGroupId.length > 0 &&
+        groupIdToken?.idToken === masterPassGroupId
+      ) {
+        logger.debug(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: IdToken with MasterPassGroupId group cannot start a transaction`
+        )
+        return {
+          status: RequestStartStopStatusEnumType.Rejected,
+          statusInfo: {
+            additionalInfo: 'MasterPassGroupId tokens cannot start transactions',
+            reasonCode: ReasonCodeEnumType.InvalidIdToken,
+          },
+          transactionId: generateUUID(),
+        }
+      }
+
       try {
         isAuthorized = this.isIdTokenAuthorized(chargingStation, idToken)
       } catch (error) {
         logger.error(
-          `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Authorization error for ${idToken.idToken}:`,
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Authorization error for ${truncateId(idToken.idToken)}:`,
           error
         )
         return {
@@ -2381,12 +2409,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
     if (!isAuthorized) {
       logger.warn(
-        `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: IdToken ${idToken.idToken} is not authorized`
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: IdToken ${truncateId(idToken.idToken)} is not authorized`
       )
       return {
         status: RequestStartStopStatusEnumType.Rejected,
         statusInfo: {
-          additionalInfo: `IdToken ${idToken.idToken} is not authorized`,
+          additionalInfo: `IdToken ${truncateId(idToken.idToken)} is not authorized`,
           reasonCode: ReasonCodeEnumType.InvalidIdToken,
         },
         transactionId: generateUUID(),
@@ -2399,7 +2427,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         isGroupAuthorized = this.isIdTokenAuthorized(chargingStation, groupIdToken)
       } catch (error) {
         logger.error(
-          `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Group authorization error for ${groupIdToken.idToken}:`,
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Group authorization error for ${truncateId(groupIdToken.idToken)}:`,
           error
         )
         return {
@@ -2414,12 +2442,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
       if (!isGroupAuthorized) {
         logger.warn(
-          `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: GroupIdToken ${groupIdToken.idToken} is not authorized`
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: GroupIdToken ${truncateId(groupIdToken.idToken)} is not authorized`
         )
         return {
           status: RequestStartStopStatusEnumType.Rejected,
           statusInfo: {
-            additionalInfo: `GroupIdToken ${groupIdToken.idToken} is not authorized`,
+            additionalInfo: `GroupIdToken ${truncateId(groupIdToken.idToken)} is not authorized`,
             reasonCode: ReasonCodeEnumType.InvalidIdToken,
           },
           transactionId: generateUUID(),
@@ -2504,6 +2532,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       connectorStatus.transactionStarted = true
       connectorStatus.transactionId = transactionId
       connectorStatus.transactionIdTag = idToken.idToken
+      connectorStatus.transactionGroupIdToken = groupIdToken?.idToken
       connectorStatus.transactionStart = new Date()
       connectorStatus.transactionEnergyActiveImportRegisterValue = 0
       connectorStatus.remoteStartId = remoteStartId
@@ -2530,7 +2559,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       }
 
       logger.info(
-        `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Remote start transaction ACCEPTED on #${connectorId.toString()} for idToken '${idToken.idToken}'`
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Remote start transaction ACCEPTED on #${connectorId.toString()} for idToken '${truncateId(idToken.idToken)}'`
       )
 
       return {
@@ -2885,6 +2914,47 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     )
   }
 
+  private isAuthorizedToStopTransaction (
+    chargingStation: ChargingStation,
+    connectorId: number,
+    presentedIdToken: OCPP20IdTokenType,
+    presentedGroupIdToken?: OCPP20IdTokenType
+  ): boolean {
+    const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+    if (connectorStatus?.transactionStarted !== true) {
+      logger.debug(
+        `${chargingStation.logPrefix()} ${moduleName}.isAuthorizedToStopTransaction: No active transaction on connector ${connectorId.toString()}`
+      )
+      return false
+    }
+
+    // C01.FR.03(a): Same idToken as the one used to start the transaction
+    if (presentedIdToken.idToken === connectorStatus.transactionIdTag) {
+      logger.debug(
+        `${chargingStation.logPrefix()} ${moduleName}.isAuthorizedToStopTransaction: Same idToken as start token - authorized locally`
+      )
+      return true
+    }
+
+    // C01.FR.03(b) / C09.FR.03 / C09.FR.07:
+    // Different valid idToken with same GroupIdToken as start → authorize locally
+    if (
+      connectorStatus.transactionGroupIdToken != null &&
+      presentedGroupIdToken?.idToken != null &&
+      presentedGroupIdToken.idToken === connectorStatus.transactionGroupIdToken
+    ) {
+      logger.debug(
+        `${chargingStation.logPrefix()} ${moduleName}.isAuthorizedToStopTransaction: Same GroupIdToken as start token - authorized locally without AuthorizationRequest`
+      )
+      return true
+    }
+
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.isAuthorizedToStopTransaction: IdToken ${truncateId(presentedIdToken.idToken)} not authorized to stop transaction on connector ${connectorId.toString()}`
+    )
+    return false
+  }
+
   /**
    * Checks if charging station is idle per OCPP 2.0.1 Errata definition.
    * Idle means: no active transactions, no firmware update in progress, no pending reservations.
@@ -2931,7 +3001,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
      */
 
     logger.debug(
-      `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: Validating idToken ${idToken.idToken} of type ${idToken.type}`
+      `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: Validating idToken ${truncateId(idToken.idToken)} of type ${idToken.type}`
     )
 
     try {
@@ -2949,12 +3019,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         const isLocalAuthorized = this.isIdTokenLocalAuthorized(chargingStation, idToken.idToken)
         if (isLocalAuthorized) {
           logger.debug(
-            `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: IdToken ${idToken.idToken} authorized via local auth list`
+            `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: IdToken ${truncateId(idToken.idToken)} authorized via local auth list`
           )
           return true
         }
         logger.debug(
-          `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: IdToken ${idToken.idToken} not found in local auth list`
+          `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: IdToken ${truncateId(idToken.idToken)} not found in local auth list`
         )
       }
 
@@ -2967,12 +3037,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       }
 
       logger.warn(
-        `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: IdToken ${idToken.idToken} authorization failed - not found in local list and remote auth not configured`
+        `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: IdToken ${truncateId(idToken.idToken)} authorization failed - not found in local list and remote auth not configured`
       )
       return false
     } catch (error) {
       logger.error(
-        `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: Error during authorization validation for ${idToken.idToken}:`,
+        `${chargingStation.logPrefix()} ${moduleName}.isIdTokenAuthorized: Error during authorization validation for ${truncateId(idToken.idToken)}:`,
         error
       )
       return false
index 118d79d3a9de0c8a151c3a9c6431fcb1ef38268e..2b86919c31b3ebfb4fec214a72d4900122272b9d 100644 (file)
@@ -26,6 +26,7 @@ import {
 } from '../../../types/index.js'
 import { OCPP20AuthorizationStatusEnumType } from '../../../types/ocpp/2.0/Transaction.js'
 import { convertToDate, logger } from '../../../utils/index.js'
+import { mapOCPP20TokenType, OCPPAuthServiceFactory } from '../auth/index.js'
 import { OCPPResponseService } from '../OCPPResponseService.js'
 import { OCPP20ServiceUtils } from './OCPP20ServiceUtils.js'
 
@@ -340,6 +341,23 @@ export class OCPP20ResponseService extends OCPPResponseService {
           )
         }
       }
+      // C10.FR.01/04/05: Update auth cache with idTokenInfo from response
+      if (requestPayload.idToken != null) {
+        const idTokenValue = requestPayload.idToken.idToken
+        const idTokenInfo = payload.idTokenInfo
+        const identifierType = mapOCPP20TokenType(requestPayload.idToken.type)
+        OCPPAuthServiceFactory.getInstance(chargingStation)
+          .then(authService => {
+            authService.updateCacheEntry(idTokenValue, idTokenInfo, identifierType)
+            return undefined
+          })
+          .catch((error: unknown) => {
+            logger.error(
+              `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error updating auth cache:`,
+              error
+            )
+          })
+      }
     }
     if (payload.updatedPersonalMessage != null) {
       logger.info(
index 5bbda90cfc43f53de149ec3602ce7f9e06ed705d..36a3d0487f5f689e2eca627caacdfb2db7a57984 100644 (file)
@@ -37,6 +37,7 @@ import type {
   OCPP20GetTransactionStatusResponse,
   OCPP20GetVariablesRequest,
   OCPP20GetVariablesResponse,
+  OCPP20IdTokenType,
   OCPP20InstallCertificateRequest,
   OCPP20InstallCertificateResponse,
   OCPP20RequestStartTransactionRequest,
@@ -239,6 +240,13 @@ export interface TestableOCPP20IncomingRequestService {
     chargingStation: ChargingStation,
     commandPayload: OCPP20UpdateFirmwareRequest
   ) => OCPP20UpdateFirmwareResponse
+
+  isAuthorizedToStopTransaction: (
+    chargingStation: ChargingStation,
+    connectorId: number,
+    presentedIdToken: OCPP20IdTokenType,
+    presentedGroupIdToken?: OCPP20IdTokenType
+  ) => boolean
 }
 
 /**
@@ -286,6 +294,7 @@ export function createTestableIncomingRequestService (
     handleRequestTriggerMessage: serviceImpl.handleRequestTriggerMessage.bind(service),
     handleRequestUnlockConnector: serviceImpl.handleRequestUnlockConnector.bind(service),
     handleRequestUpdateFirmware: serviceImpl.handleRequestUpdateFirmware.bind(service),
+    isAuthorizedToStopTransaction: serviceImpl.isAuthorizedToStopTransaction.bind(service),
   }
 }
 
index b4c68f21caf7e46481354b7bdd73766747895f1d..0b34715c8786735aa6fc77a414b21c99829a8054 100644 (file)
@@ -284,7 +284,7 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter {
       return remoteAuthEnabled && isOnline
     } catch (error) {
       logger.warn(
-        `${this.chargingStation.logPrefix()} Error checking remote authorization availability`,
+        `${this.chargingStation.logPrefix()} ${moduleName}.isRemoteAvailable: Error checking remote authorization availability`,
         error
       )
       return false
@@ -331,7 +331,7 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter {
 
       if (!hasLocalAuth && !hasRemoteAuth) {
         logger.warn(
-          `${this.chargingStation.logPrefix()} OCPP 1.6 adapter: No authorization methods enabled`
+          `${this.chargingStation.logPrefix()} ${moduleName}.validateConfiguration: No authorization methods enabled`
         )
         return false
       }
@@ -339,7 +339,7 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter {
       // Validate timeout values
       if (config.authorizationTimeout < 1) {
         logger.warn(
-          `${this.chargingStation.logPrefix()} OCPP 1.6 adapter: Invalid authorization timeout`
+          `${this.chargingStation.logPrefix()} ${moduleName}.validateConfiguration: Invalid authorization timeout`
         )
         return false
       }
@@ -347,7 +347,7 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter {
       return true
     } catch (error) {
       logger.error(
-        `${this.chargingStation.logPrefix()} OCPP 1.6 adapter configuration validation failed`,
+        `${this.chargingStation.logPrefix()} ${moduleName}.validateConfiguration: Configuration validation failed`,
         error
       )
       return false
@@ -367,7 +367,7 @@ export class OCPP16AuthAdapter implements OCPPAuthAdapter {
       return configKey?.value === 'true'
     } catch (error) {
       logger.warn(
-        `${this.chargingStation.logPrefix()} Error getting offline transaction config`,
+        `${this.chargingStation.logPrefix()} ${moduleName}.getOfflineTransactionConfig: Error getting offline transaction config`,
         error
       )
       return false
index 75fac487a47ca7c55b79de58c6f291bcd90092e4..d481738e1ed3267095f54d7273533b3ddea6f054 100644 (file)
@@ -168,7 +168,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
         // Map OCPP 2.0 authorization status to unified status
         const unifiedStatus = this.mapOCPP20AuthStatus(authStatus)
 
-        logger.info(
+        logger.debug(
           `${this.chargingStation.logPrefix()} ${moduleName}.${methodName}: Authorization result for ${idToken.idToken}: ${authStatus} (unified: ${unifiedStatus})`
         )
 
@@ -453,7 +453,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
       return isOnline && remoteStartEnabled
     } catch (error) {
       logger.warn(
-        `${this.chargingStation.logPrefix()} Error checking remote authorization availability`,
+        `${this.chargingStation.logPrefix()} ${moduleName}.isRemoteAvailable: Error checking remote authorization availability`,
         error
       )
       return false
@@ -506,7 +506,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
 
       if (!hasRemoteAuth && !hasLocalAuth && !hasCertAuth) {
         logger.warn(
-          `${this.chargingStation.logPrefix()} OCPP 2.0 adapter: No authorization methods enabled`
+          `${this.chargingStation.logPrefix()} ${moduleName}.validateConfiguration: No authorization methods enabled`
         )
         return false
       }
@@ -514,7 +514,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
       // Validate timeout values
       if (config.authorizationTimeout < 1) {
         logger.warn(
-          `${this.chargingStation.logPrefix()} OCPP 2.0 adapter: Invalid authorization timeout`
+          `${this.chargingStation.logPrefix()} ${moduleName}.validateConfiguration: Invalid authorization timeout`
         )
         return false
       }
@@ -522,7 +522,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
       return true
     } catch (error) {
       logger.error(
-        `${this.chargingStation.logPrefix()} OCPP 2.0 adapter configuration validation failed`,
+        `${this.chargingStation.logPrefix()} ${moduleName}.validateConfiguration: Configuration validation failed`,
         error
       )
       return false
@@ -577,7 +577,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
       return true
     } catch (error) {
       logger.warn(
-        `${this.chargingStation.logPrefix()} Error getting offline authorization config`,
+        `${this.chargingStation.logPrefix()} ${moduleName}.getOfflineAuthorizationConfig: Error getting offline authorization config`,
         error
       )
       return false
@@ -609,7 +609,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
       // Check if variable was successfully retrieved
       if (results.length === 0) {
         logger.debug(
-          `${this.chargingStation.logPrefix()} Variable ${component}.${variable} not found in registry`
+          `${this.chargingStation.logPrefix()} ${moduleName}.getVariableValue: Variable ${component}.${variable} not found in registry`
         )
         return this.getDefaultVariableValue(component, variable, useDefaultFallback)
       }
@@ -622,7 +622,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
         result.attributeValue == null
       ) {
         logger.debug(
-          `${this.chargingStation.logPrefix()} Variable ${component}.${variable} not available: ${result.attributeStatus}`
+          `${this.chargingStation.logPrefix()} ${moduleName}.getVariableValue: Variable ${component}.${variable} not available: ${result.attributeStatus}`
         )
         return this.getDefaultVariableValue(component, variable, useDefaultFallback)
       }
@@ -630,7 +630,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
       return result.attributeValue
     } catch (error) {
       logger.warn(
-        `${this.chargingStation.logPrefix()} Error getting variable ${component}.${variable}`,
+        `${this.chargingStation.logPrefix()} ${moduleName}.getVariableValue: Error getting variable ${component}.${variable}`,
         error
       )
       return this.getDefaultVariableValue(component, variable, useDefaultFallback)
@@ -749,7 +749,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
     }
 
     logger.warn(
-      `${this.chargingStation.logPrefix()} Invalid boolean value '${value}', using default: ${defaultValue.toString()}`
+      `${this.chargingStation.logPrefix()} ${moduleName}.parseBooleanVariable: Invalid boolean value '${value}', using default: ${defaultValue.toString()}`
     )
     return defaultValue
   }
@@ -776,7 +776,7 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
 
     if (isNaN(parsed)) {
       logger.warn(
-        `${this.chargingStation.logPrefix()} Invalid integer value '${value}', using default: ${defaultValue.toString()}`
+        `${this.chargingStation.logPrefix()} ${moduleName}.parseIntegerVariable: Invalid integer value '${value}', using default: ${defaultValue.toString()}`
       )
       return defaultValue
     }
@@ -784,14 +784,14 @@ export class OCPP20AuthAdapter implements OCPPAuthAdapter {
     // Validate range
     if (min != null && parsed < min) {
       logger.warn(
-        `${this.chargingStation.logPrefix()} Integer value ${parsed.toString()} below minimum ${min.toString()}, using minimum`
+        `${this.chargingStation.logPrefix()} ${moduleName}.parseIntegerVariable: Integer value ${parsed.toString()} below minimum ${min.toString()}, using minimum`
       )
       return min
     }
 
     if (max != null && parsed > max) {
       logger.warn(
-        `${this.chargingStation.logPrefix()} Integer value ${parsed.toString()} above maximum ${max.toString()}, using maximum`
+        `${this.chargingStation.logPrefix()} ${moduleName}.parseIntegerVariable: Integer value ${parsed.toString()} above maximum ${max.toString()}, using maximum`
       )
       return max
     }
index 44f3a2daa514d1bc05edf236ead9ec5f3f2f9903..b875570a7f86d02e62a29964cdbefba610141da1 100644 (file)
@@ -1,9 +1,11 @@
 import type { AuthCache, CacheStats } from '../interfaces/OCPPAuthService.js'
 import type { AuthorizationResult } from '../types/AuthTypes.js'
 
-import { logger } from '../../../../utils/index.js'
+import { logger, truncateId } from '../../../../utils/index.js'
 import { AuthorizationStatus } from '../types/AuthTypes.js'
 
+const moduleName = 'InMemoryAuthCache'
+
 /**
  * Cached authorization entry with expiration
  */
@@ -137,7 +139,7 @@ export class InMemoryAuthCache implements AuthCache {
     }
 
     logger.info(
-      `InMemoryAuthCache: Initialized with maxEntries=${String(this.maxEntries)}, defaultTtl=${String(this.defaultTtl)}s, rateLimit=${this.rateLimit.enabled ? `${String(this.rateLimit.maxRequests)} req/${String(this.rateLimit.windowMs)}ms` : 'disabled'}`
+      `${moduleName}: Initialized with maxEntries=${String(this.maxEntries)}, defaultTtl=${String(this.defaultTtl)}s, rateLimit=${this.rateLimit.enabled ? `${String(this.rateLimit.maxRequests)} req/${String(this.rateLimit.windowMs)}ms` : 'disabled'}`
     )
   }
 
@@ -150,7 +152,7 @@ export class InMemoryAuthCache implements AuthCache {
     this.lruOrder.clear()
     this.rateLimits.clear()
 
-    logger.info(`InMemoryAuthCache: Cleared ${String(entriesCleared)} entries`)
+    logger.info(`${moduleName}: Cleared ${String(entriesCleared)} entries`)
   }
 
   public dispose (): void {
@@ -169,9 +171,7 @@ export class InMemoryAuthCache implements AuthCache {
     // Check rate limiting first
     if (!this.checkRateLimit(identifier)) {
       this.stats.rateLimitBlocked++
-      logger.warn(
-        `InMemoryAuthCache: Rate limit exceeded for identifier: ${this.truncateId(identifier)}`
-      )
+      logger.warn(`${moduleName}: Rate limit exceeded for identifier: ${truncateId(identifier)}`)
       return undefined
     }
 
@@ -198,7 +198,7 @@ export class InMemoryAuthCache implements AuthCache {
       }
       this.lruOrder.set(identifier, now)
       logger.debug(
-        `InMemoryAuthCache: Expired entry transitioned to EXPIRED for identifier: ${this.truncateId(identifier)}`
+        `${moduleName}: Expired entry transitioned to EXPIRED for identifier: ${truncateId(identifier)}`
       )
       return entry.result
     }
@@ -213,7 +213,7 @@ export class InMemoryAuthCache implements AuthCache {
       entry.expiresAt = now + this.defaultTtl * 1000
     }
 
-    logger.debug(`InMemoryAuthCache: Cache hit for identifier: ${this.truncateId(identifier)}`)
+    logger.debug(`${moduleName}: Cache hit for identifier: ${truncateId(identifier)}`)
     return entry.result
   }
 
@@ -261,9 +261,7 @@ export class InMemoryAuthCache implements AuthCache {
     this.lruOrder.delete(identifier)
 
     if (deleted) {
-      logger.debug(
-        `InMemoryAuthCache: Removed entry for identifier: ${this.truncateId(identifier)}`
-      )
+      logger.debug(`${moduleName}: Removed entry for identifier: ${truncateId(identifier)}`)
     }
   }
 
@@ -317,7 +315,7 @@ export class InMemoryAuthCache implements AuthCache {
     if (!this.checkRateLimit(identifier)) {
       this.stats.rateLimitBlocked++
       logger.warn(
-        `InMemoryAuthCache: Rate limit exceeded, not caching identifier: ${this.truncateId(identifier)}`
+        `${moduleName}: Rate limit exceeded, not caching identifier: ${truncateId(identifier)}`
       )
       return
     }
@@ -343,7 +341,7 @@ export class InMemoryAuthCache implements AuthCache {
     this.stats.sets++
 
     logger.debug(
-      `InMemoryAuthCache: Cached result for identifier: ${this.truncateId(identifier)}, ttl=${String(clampedTtl)}s, entries=${String(this.cache.size)}/${String(this.maxEntries)}`
+      `${moduleName}: Cached result for identifier: ${truncateId(identifier)}, ttl=${String(clampedTtl)}s, entries=${String(this.cache.size)}/${String(this.maxEntries)}`
     )
   }
 
@@ -448,24 +446,7 @@ export class InMemoryAuthCache implements AuthCache {
       this.cache.delete(candidateIdentifier)
       this.lruOrder.delete(candidateIdentifier)
       this.stats.evictions++
-      logger.debug(`InMemoryAuthCache: Evicted LRU entry: ${this.truncateId(candidateIdentifier)}`)
+      logger.debug(`${moduleName}: Evicted LRU entry: ${truncateId(candidateIdentifier)}`)
     }
   }
-
-  private truncateId (identifier: string, maxLen = 8): string {
-    return truncateId(identifier, maxLen)
-  }
-}
-
-/**
- * Truncate identifier for safe log output.
- * @param identifier - Full identifier string
- * @param maxLen - Maximum length before truncation (default: 8)
- * @returns Truncated identifier with '...' suffix, or original if within limit
- */
-export function truncateId (identifier: string, maxLen = 8): string {
-  if (identifier.length <= maxLen) {
-    return identifier
-  }
-  return `${identifier.slice(0, maxLen)}...`
 }
index bd323c8af0b00cb9a8693135f9bae5f98e2e7df1..57a79faab4a65907a8f792628fe9b8bf4b870be0 100644 (file)
@@ -68,18 +68,17 @@ export {
   type AuthRequest,
   type CertificateHashData,
   IdentifierType,
-  type UnifiedIdentifier,
-} from './types/AuthTypes.js'
-export {
   isCertificateBased,
   isOCPP16Type,
   isOCPP20Type,
   mapOCPP16Status,
+  mapOCPP20AuthorizationStatus,
   mapOCPP20TokenType,
   mapToOCPP16Status,
   mapToOCPP20Status,
   mapToOCPP20TokenType,
   requiresAdditionalInfo,
+  type UnifiedIdentifier,
 } from './types/AuthTypes.js'
 
 // ============================================================================
index ffe608f1c25108a2d130ff9cab085237e2c42b8f..6fc794bee1c201a44b7c19970f80257ace89d7a4 100644 (file)
@@ -1,3 +1,4 @@
+import type { OCPP20IdTokenInfoType } from '../../../../types/ocpp/2.0/Transaction.js'
 import type { OCPPVersion } from '../../../../types/ocpp/OCPPVersion.js'
 import type {
   AuthConfiguration,
@@ -5,6 +6,7 @@ import type {
   AuthRequest,
   UnifiedIdentifier,
 } from '../types/AuthTypes.js'
+import type { IdentifierType } from '../types/AuthTypes.js'
 
 /**
  * Authorization cache interface
@@ -425,6 +427,18 @@ export interface OCPPAuthService {
    */
   testConnectivity(): boolean
 
+  /**
+   * Update a cache entry from TransactionEventResponse idTokenInfo (C10.FR.01/04/05)
+   * @param identifier - The idToken string to cache
+   * @param idTokenInfo - The idTokenInfo from the CSMS response
+   * @param identifierType - Optional identifier type for cache skip logic (C02.FR.03/C03.FR.02)
+   */
+  updateCacheEntry(
+    identifier: string,
+    idTokenInfo: OCPP20IdTokenInfoType,
+    identifierType?: IdentifierType
+  ): void
+
   /**
    * Update authentication configuration
    * @param config - New configuration to apply
index 452d8dc240e3d0857def1f45295812418fc4fcc1..36a0605930fa3126e47b1f54df694acdb3af077f 100644 (file)
@@ -1,3 +1,4 @@
+import type { OCPP20IdTokenInfoType } from '../../../../types/ocpp/2.0/Transaction.js'
 import type { OCPP16AuthAdapter } from '../adapters/OCPP16AuthAdapter.js'
 import type { OCPP20AuthAdapter } from '../adapters/OCPP20AuthAdapter.js'
 import type { LocalAuthStrategy } from '../strategies/LocalAuthStrategy.js'
@@ -5,7 +6,13 @@ import type { LocalAuthStrategy } from '../strategies/LocalAuthStrategy.js'
 import { OCPPError } from '../../../../exception/OCPPError.js'
 import { ErrorType } from '../../../../types/index.js'
 import { OCPPVersion } from '../../../../types/ocpp/OCPPVersion.js'
-import { ensureError, getErrorMessage, logger } from '../../../../utils/index.js'
+import {
+  convertToDate,
+  ensureError,
+  getErrorMessage,
+  logger,
+  truncateId,
+} from '../../../../utils/index.js'
 import { type ChargingStation } from '../../../ChargingStation.js'
 import { AuthComponentFactory } from '../factories/AuthComponentFactory.js'
 import {
@@ -21,10 +28,13 @@ import {
   AuthorizationStatus,
   type AuthRequest,
   IdentifierType,
+  mapOCPP20AuthorizationStatus,
   type UnifiedIdentifier,
 } from '../types/AuthTypes.js'
 import { AuthConfigValidator } from '../utils/ConfigValidator.js'
 
+const moduleName = 'OCPPAuthServiceImpl'
+
 export class OCPPAuthServiceImpl implements OCPPAuthService {
   private readonly adapters: Map<OCPPVersion, OCPP16AuthAdapter | OCPP20AuthAdapter>
   private readonly chargingStation: ChargingStation
@@ -82,7 +92,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
     this.metrics.totalRequests++
 
     logger.debug(
-      `${this.chargingStation.logPrefix()} Starting authentication for identifier: ${JSON.stringify(request.identifier)}`
+      `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Starting authentication for identifier: ${JSON.stringify(request.identifier)}`
     )
 
     // Try each strategy in priority order
@@ -91,28 +101,28 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
 
       if (!strategy) {
         logger.debug(
-          `${this.chargingStation.logPrefix()} Strategy '${strategyName}' not available, skipping`
+          `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Strategy '${strategyName}' not available, skipping`
         )
         continue
       }
 
       if (!strategy.canHandle(request, this.config)) {
         logger.debug(
-          `${this.chargingStation.logPrefix()} Strategy '${strategyName}' cannot handle request, skipping`
+          `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Strategy '${strategyName}' cannot handle request, skipping`
         )
         continue
       }
 
       try {
         logger.debug(
-          `${this.chargingStation.logPrefix()} Trying authentication strategy: ${strategyName}`
+          `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Trying authentication strategy: ${strategyName}`
         )
 
         const result = await strategy.authenticate(request, this.config)
 
         if (!result) {
           logger.debug(
-            `${this.chargingStation.logPrefix()} Strategy '${strategyName}' returned no result, continuing to next strategy`
+            `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Strategy '${strategyName}' returned no result, continuing to next strategy`
           )
           continue
         }
@@ -123,7 +133,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
         this.updateMetricsForResult(result, strategyName, duration)
 
         logger.info(
-          `${this.chargingStation.logPrefix()} Authentication successful using ${strategyName} strategy (${String(duration)}ms): ${result.status}`
+          `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Authentication successful using ${strategyName} strategy (${String(duration)}ms): ${result.status}`
         )
 
         return {
@@ -146,7 +156,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
       } catch (error) {
         lastError = ensureError(error)
         logger.debug(
-          `${this.chargingStation.logPrefix()} Strategy '${strategyName}' failed: ${getErrorMessage(error)}`
+          `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Strategy '${strategyName}' failed: ${getErrorMessage(error)}`
         )
 
         // Continue to next strategy unless it's a critical error
@@ -165,7 +175,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
     this.metrics.totalResponseTime += duration
 
     logger.error(
-      `${this.chargingStation.logPrefix()} Authentication failed for all strategies (${String(duration)}ms): ${errorMessage}`
+      `${this.chargingStation.logPrefix()} ${moduleName}.authenticate: Authentication failed for all strategies (${String(duration)}ms): ${errorMessage}`
     )
 
     return {
@@ -238,7 +248,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
       const duration = Date.now() - startTime
 
       logger.info(
-        `${this.chargingStation.logPrefix()} Direct authentication with ${strategyName} successful (${String(duration)}ms): ${result.status}`
+        `${this.chargingStation.logPrefix()} ${moduleName}.authorizeWithStrategy: Direct authentication with ${strategyName} successful (${String(duration)}ms): ${result.status}`
       )
 
       return {
@@ -258,7 +268,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
     } catch (error) {
       const duration = Date.now() - startTime
       logger.error(
-        `${this.chargingStation.logPrefix()} Direct authentication with ${strategyName} failed (${String(duration)}ms): ${getErrorMessage(error)}`
+        `${this.chargingStation.logPrefix()} ${moduleName}.authorizeWithStrategy: Direct authentication with ${strategyName} failed (${String(duration)}ms): ${getErrorMessage(error)}`
       )
       throw error
     }
@@ -268,16 +278,22 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
    * Clear all cached authorizations
    */
   public clearCache (): void {
-    logger.debug(`${this.chargingStation.logPrefix()} Clearing all cached authorizations`)
+    logger.debug(
+      `${this.chargingStation.logPrefix()} ${moduleName}.clearCache: Clearing all cached authorizations`
+    )
 
     // Clear cache in local strategy
     const localStrategy = this.strategies.get('local') as LocalAuthStrategy | undefined
     const localAuthCache = localStrategy?.getAuthCache()
     if (localAuthCache) {
       localAuthCache.clear()
-      logger.info(`${this.chargingStation.logPrefix()} Authorization cache cleared`)
+      logger.info(
+        `${this.chargingStation.logPrefix()} ${moduleName}.clearCache: Authorization cache cleared`
+      )
     } else {
-      logger.debug(`${this.chargingStation.logPrefix()} No authorization cache available to clear`)
+      logger.debug(
+        `${this.chargingStation.logPrefix()} ${moduleName}.clearCache: No authorization cache available to clear`
+      )
     }
   }
 
@@ -415,7 +431,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
    */
   public invalidateCache (identifier: UnifiedIdentifier): void {
     logger.debug(
-      `${this.chargingStation.logPrefix()} Invalidating cache for identifier: ${identifier.value}`
+      `${this.chargingStation.logPrefix()} ${moduleName}.invalidateCache: Invalidating cache for identifier: ${identifier.value}`
     )
 
     // Invalidate in local strategy
@@ -423,11 +439,11 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
     if (localStrategy) {
       localStrategy.invalidateCache(identifier.value)
       logger.info(
-        `${this.chargingStation.logPrefix()} Cache invalidated for identifier: ${identifier.value}`
+        `${this.chargingStation.logPrefix()} ${moduleName}.invalidateCache: Cache invalidated for identifier: ${identifier.value}`
       )
     } else {
       logger.debug(
-        `${this.chargingStation.logPrefix()} No local strategy available for cache invalidation`
+        `${this.chargingStation.logPrefix()} ${moduleName}.invalidateCache: No local strategy available for cache invalidation`
       )
     }
   }
@@ -461,7 +477,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
         }
       } catch (error) {
         logger.debug(
-          `${this.chargingStation.logPrefix()} Local authorization check failed: ${getErrorMessage(error)}`
+          `${this.chargingStation.logPrefix()} ${moduleName}.isLocallyAuthorized: Local authorization check failed: ${getErrorMessage(error)}`
         )
       }
     }
@@ -504,6 +520,63 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
     return true
   }
 
+  public updateCacheEntry (
+    identifier: string,
+    idTokenInfo: OCPP20IdTokenInfoType,
+    identifierType?: IdentifierType
+  ): void {
+    if (!this.config.authorizationCacheEnabled) {
+      return
+    }
+
+    if (
+      identifierType === IdentifierType.NO_AUTHORIZATION ||
+      identifierType === IdentifierType.CENTRAL
+    ) {
+      logger.debug(
+        `${this.chargingStation.logPrefix()} ${moduleName}.updateCacheEntry: Skipping cache for ${identifierType} identifier type`
+      )
+      return
+    }
+
+    const localStrategy = this.strategies.get('local') as LocalAuthStrategy | undefined
+    const authCache = localStrategy?.getAuthCache()
+    if (authCache == null) {
+      logger.debug(
+        `${this.chargingStation.logPrefix()} ${moduleName}.updateCacheEntry: No auth cache available`
+      )
+      return
+    }
+
+    const unifiedStatus = mapOCPP20AuthorizationStatus(idTokenInfo.status)
+
+    const result: AuthorizationResult = {
+      isOffline: false,
+      method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+      status: unifiedStatus,
+      timestamp: new Date(),
+    }
+
+    let ttl: number | undefined
+    if (idTokenInfo.cacheExpiryDateTime != null) {
+      const expiryDate = convertToDate(idTokenInfo.cacheExpiryDateTime)
+      if (expiryDate != null) {
+        const expiryMs = expiryDate.getTime()
+        const ttlSeconds = Math.ceil((expiryMs - Date.now()) / 1000)
+        if (ttlSeconds > 0) {
+          ttl = ttlSeconds
+        }
+      }
+    }
+    ttl ??= this.config.authorizationCacheLifetime
+
+    authCache.set(identifier, result, ttl)
+
+    logger.debug(
+      `${this.chargingStation.logPrefix()} ${moduleName}.updateCacheEntry: Updated cache for ${truncateId(identifier)} status=${unifiedStatus}, ttl=${ttl != null ? ttl.toString() : 'default'}s`
+    )
+  }
+
   /**
    * Update authentication configuration
    * @param config - Partial configuration object with values to update
@@ -519,7 +592,9 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
     // Apply validated configuration
     this.config = newConfig
 
-    logger.info(`${this.chargingStation.logPrefix()} Authentication configuration updated`)
+    logger.info(
+      `${this.chargingStation.logPrefix()} ${moduleName}.updateConfiguration: Authentication configuration updated`
+    )
   }
 
   /**
@@ -551,11 +626,11 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
     if (isConfigurable(strategy)) {
       strategy.configure(config)
       logger.info(
-        `${this.chargingStation.logPrefix()} Updated configuration for strategy: ${strategyName}`
+        `${this.chargingStation.logPrefix()} ${moduleName}.updateStrategyConfiguration: Updated configuration for strategy: ${strategyName}`
       )
     } else {
       logger.warn(
-        `${this.chargingStation.logPrefix()} Strategy '${strategyName}' does not support runtime configuration updates`
+        `${this.chargingStation.logPrefix()} ${moduleName}.updateStrategyConfiguration: Strategy '${strategyName}' does not support runtime configuration updates`
       )
     }
   }
@@ -633,7 +708,7 @@ export class OCPPAuthServiceImpl implements OCPPAuthService {
     })
 
     logger.info(
-      `${this.chargingStation.logPrefix()} Initialized ${String(this.strategies.size)} authentication strategies for OCPP ${ocppVersion ?? 'unknown'}`
+      `${this.chargingStation.logPrefix()} ${moduleName}.initializeStrategies: Initialized ${String(this.strategies.size)} authentication strategies for OCPP ${ocppVersion ?? 'unknown'}`
     )
   }
 
index 650f19153b73a84c0076a513b49b5392cbe8a866..118a977a6a69d2234063b9aaa004afb8fc3504ea 100644 (file)
@@ -11,6 +11,8 @@ import { OCPPVersion } from '../../../../types/index.js'
 import { isNotEmptyString, logger, sleep } from '../../../../utils/index.js'
 import { AuthenticationMethod, AuthorizationStatus, IdentifierType } from '../types/AuthTypes.js'
 
+const moduleName = 'CertificateAuthStrategy'
+
 const CERTIFICATE_VERIFY_DELAY_MS = 100
 
 /**
@@ -61,7 +63,7 @@ export class CertificateAuthStrategy implements AuthStrategy {
       const certValidation = this.validateCertificateData(request.identifier)
       if (!certValidation.isValid) {
         logger.warn(
-          `${this.chargingStation.logPrefix()} Certificate validation failed: ${String(certValidation.reason)}`
+          `${moduleName}: Certificate validation failed: ${String(certValidation.reason)}`
         )
         return this.createFailureResult(
           AuthorizationStatus.INVALID,
@@ -97,7 +99,7 @@ export class CertificateAuthStrategy implements AuthStrategy {
         startTime
       )
     } catch (error) {
-      logger.error(`${this.chargingStation.logPrefix()} Certificate authorization error:`, error)
+      logger.error(`${moduleName}: Certificate authorization error:`, error)
       return this.createFailureResult(
         AuthorizationStatus.INVALID,
         'Certificate authorization failed',
@@ -138,9 +140,7 @@ export class CertificateAuthStrategy implements AuthStrategy {
 
   cleanup (): void {
     this.isInitialized = false
-    logger.debug(
-      `${this.chargingStation.logPrefix()} Certificate authentication strategy cleaned up`
-    )
+    logger.debug(`${moduleName}: Certificate authentication strategy cleaned up`)
   }
 
   getStats (): Record<string, unknown> {
@@ -152,13 +152,11 @@ export class CertificateAuthStrategy implements AuthStrategy {
 
   initialize (config: AuthConfiguration): void {
     if (!config.certificateAuthEnabled) {
-      logger.info(`${this.chargingStation.logPrefix()} Certificate authentication disabled`)
+      logger.info(`${moduleName}: Certificate authentication disabled`)
       return
     }
 
-    logger.info(
-      `${this.chargingStation.logPrefix()} Certificate authentication strategy initialized`
-    )
+    logger.info(`${moduleName}: Certificate authentication strategy initialized`)
     this.isInitialized = true
   }
 
@@ -392,7 +390,7 @@ export class CertificateAuthStrategy implements AuthStrategy {
         }
 
         logger.info(
-          `${this.chargingStation.logPrefix()} Certificate authorization successful for certificate ${request.identifier.certificateHashData?.serialNumber ?? 'unknown'}`
+          `${moduleName}: Certificate authorization successful for certificate ${request.identifier.certificateHashData?.serialNumber ?? 'unknown'}`
         )
 
         return successResult
@@ -405,10 +403,7 @@ export class CertificateAuthStrategy implements AuthStrategy {
         )
       }
     } catch (error) {
-      logger.error(
-        `${this.chargingStation.logPrefix()} OCPP 2.0 certificate validation error:`,
-        error
-      )
+      logger.error(`${moduleName}: OCPP 2.0 certificate validation error:`, error)
       return this.createFailureResult(
         AuthorizationStatus.INVALID,
         'Certificate validation error',
index b151b8e5c309fa40e8afe81a7f5c1856118ab93b..181b95aa69f5a4853d1d531b2c4610578b14b4f9 100644 (file)
@@ -5,7 +5,7 @@ import type {
 } from '../interfaces/OCPPAuthService.js'
 import type { AuthConfiguration, AuthorizationResult, AuthRequest } from '../types/AuthTypes.js'
 
-import { ensureError, getErrorMessage, logger } from '../../../../utils/index.js'
+import { ensureError, getErrorMessage, logger, truncateId } from '../../../../utils/index.js'
 import {
   AuthContext,
   AuthenticationError,
@@ -14,6 +14,8 @@ import {
   AuthorizationStatus,
 } from '../types/AuthTypes.js'
 
+const moduleName = 'LocalAuthStrategy'
+
 /**
  * Local Authentication Strategy
  *
@@ -68,15 +70,22 @@ export class LocalAuthStrategy implements AuthStrategy {
 
     try {
       logger.debug(
-        `LocalAuthStrategy: Authenticating ${request.identifier.value} for ${request.context}`
+        `${moduleName}: Authenticating ${request.identifier.value} for ${request.context}`
       )
 
       // 1. Try local authorization list first (highest priority)
       if (config.localAuthListEnabled && this.localAuthListManager) {
         const localResult = await this.checkLocalAuthList(request, config)
         if (localResult) {
-          logger.debug(`LocalAuthStrategy: Found in local auth list: ${localResult.status}`)
+          logger.debug(`${moduleName}: Found in local auth list: ${localResult.status}`)
           this.stats.localListHits++
+          // C14.FR.03: non-Accepted local list tokens trigger re-auth unless DisablePostAuthorize
+          if (this.shouldTriggerPostAuthorize(localResult, config)) {
+            logger.debug(
+              `${moduleName}: Local list token non-Accepted (${localResult.status}), deferring to remote auth`
+            )
+            return undefined
+          }
           return this.enhanceResult(localResult, AuthenticationMethod.LOCAL_LIST, startTime)
         }
       }
@@ -85,8 +94,15 @@ export class LocalAuthStrategy implements AuthStrategy {
       if (config.authorizationCacheEnabled && this.authCache) {
         const cacheResult = this.checkAuthCache(request, config)
         if (cacheResult) {
-          logger.debug(`LocalAuthStrategy: Found in cache: ${cacheResult.status}`)
+          logger.debug(`${moduleName}: Found in cache: ${cacheResult.status}`)
           this.stats.cacheHits++
+          // C10.FR.03, C12.FR.05: non-Accepted cached tokens trigger re-auth unless DisablePostAuthorize
+          if (this.shouldTriggerPostAuthorize(cacheResult, config)) {
+            logger.debug(
+              `${moduleName}: Cached token non-Accepted (${cacheResult.status}), deferring to remote auth`
+            )
+            return undefined
+          }
           return this.enhanceResult(cacheResult, AuthenticationMethod.CACHE, startTime)
         }
       }
@@ -95,19 +111,17 @@ export class LocalAuthStrategy implements AuthStrategy {
       if (config.offlineAuthorizationEnabled && request.allowOffline) {
         const offlineResult = this.handleOfflineFallback(request, config)
         if (offlineResult) {
-          logger.debug(`LocalAuthStrategy: Offline fallback: ${offlineResult.status}`)
+          logger.debug(`${moduleName}: Offline fallback: ${offlineResult.status}`)
           this.stats.offlineDecisions++
           return this.enhanceResult(offlineResult, AuthenticationMethod.OFFLINE_FALLBACK, startTime)
         }
       }
 
-      logger.debug(
-        `LocalAuthStrategy: No local authorization found for ${request.identifier.value}`
-      )
+      logger.debug(`${moduleName}: No local authorization found for ${request.identifier.value}`)
       return undefined
     } catch (error) {
       const errorMessage = getErrorMessage(error)
-      logger.error(`LocalAuthStrategy: Authentication error: ${errorMessage}`)
+      logger.error(`${moduleName}: Authentication error: ${errorMessage}`)
       throw new AuthenticationError(
         `Local authentication failed: ${errorMessage}`,
         AuthErrorCode.STRATEGY_ERROR,
@@ -135,10 +149,10 @@ export class LocalAuthStrategy implements AuthStrategy {
 
     try {
       this.authCache.set(identifier, result, ttl)
-      logger.debug(`LocalAuthStrategy: Cached result for ${identifier}`)
+      logger.debug(`${moduleName}: Cached result for ${truncateId(identifier)}`)
     } catch (error) {
       const errorMessage = getErrorMessage(error)
-      logger.error(`LocalAuthStrategy: Failed to cache result: ${errorMessage}`)
+      logger.error(`${moduleName}: Failed to cache result: ${errorMessage}`)
       // Don't throw - caching is not critical
     }
   }
@@ -162,7 +176,7 @@ export class LocalAuthStrategy implements AuthStrategy {
    * Cleanup strategy resources
    */
   public cleanup (): void {
-    logger.info('LocalAuthStrategy: Cleaning up...')
+    logger.info(`${moduleName}: Cleaning up...`)
 
     // Reset internal state
     this.isInitialized = false
@@ -174,7 +188,7 @@ export class LocalAuthStrategy implements AuthStrategy {
       totalRequests: 0,
     }
 
-    logger.info('LocalAuthStrategy: Cleanup completed')
+    logger.info(`${moduleName}: Cleanup completed`)
   }
 
   /**
@@ -217,30 +231,30 @@ export class LocalAuthStrategy implements AuthStrategy {
    */
   public initialize (config: AuthConfiguration): void {
     try {
-      logger.info('LocalAuthStrategy: Initializing...')
+      logger.info(`${moduleName}: Initializing...`)
 
       if (config.localAuthListEnabled && !this.localAuthListManager) {
-        logger.warn('LocalAuthStrategy: Local auth list enabled but no manager provided')
+        logger.warn(`${moduleName}: Local auth list enabled but no manager provided`)
       }
 
       if (config.authorizationCacheEnabled && !this.authCache) {
-        logger.warn('LocalAuthStrategy: Authorization cache enabled but no cache provided')
+        logger.warn(`${moduleName}: Authorization cache enabled but no cache provided`)
       }
 
       // Initialize components if available
       if (this.localAuthListManager) {
-        logger.debug('LocalAuthStrategy: Local auth list manager available')
+        logger.debug(`${moduleName}: Local auth list manager available`)
       }
 
       if (this.authCache) {
-        logger.debug('LocalAuthStrategy: Authorization cache available')
+        logger.debug(`${moduleName}: Authorization cache available`)
       }
 
       this.isInitialized = true
-      logger.info('LocalAuthStrategy: Initialized successfully')
+      logger.info(`${moduleName}: Initialized successfully`)
     } catch (error) {
       const errorMessage = getErrorMessage(error)
-      logger.error(`LocalAuthStrategy: Initialization failed: ${errorMessage}`)
+      logger.error(`${moduleName}: Initialization failed: ${errorMessage}`)
       throw new AuthenticationError(
         `Local auth strategy initialization failed: ${errorMessage}`,
         AuthErrorCode.CONFIGURATION_ERROR,
@@ -260,10 +274,10 @@ export class LocalAuthStrategy implements AuthStrategy {
 
     try {
       this.authCache.remove(identifier)
-      logger.debug(`LocalAuthStrategy: Invalidated cache for ${identifier}`)
+      logger.debug(`${moduleName}: Invalidated cache for ${truncateId(identifier)}`)
     } catch (error) {
       const errorMessage = getErrorMessage(error)
-      logger.error(`LocalAuthStrategy: Failed to invalidate cache: ${errorMessage}`)
+      logger.error(`${moduleName}: Failed to invalidate cache: ${errorMessage}`)
       // Don't throw - cache invalidation errors are not critical
     }
   }
@@ -283,7 +297,7 @@ export class LocalAuthStrategy implements AuthStrategy {
       return !!entry
     } catch (error) {
       const errorMessage = getErrorMessage(error)
-      logger.error(`LocalAuthStrategy: Error checking local list: ${errorMessage}`)
+      logger.error(`${moduleName}: Error checking local list: ${errorMessage}`)
       return false
     }
   }
@@ -324,11 +338,11 @@ export class LocalAuthStrategy implements AuthStrategy {
         return undefined
       }
 
-      logger.debug(`LocalAuthStrategy: Cache hit for ${request.identifier.value}`)
+      logger.debug(`${moduleName}: Cache hit for ${truncateId(request.identifier.value)}`)
       return cachedResult
     } catch (error) {
       const errorMessage = getErrorMessage(error)
-      logger.error(`LocalAuthStrategy: Cache check failed: ${errorMessage}`)
+      logger.error(`${moduleName}: Cache check failed: ${errorMessage}`)
       throw new AuthenticationError(
         `Authorization cache check failed: ${errorMessage}`,
         AuthErrorCode.CACHE_ERROR,
@@ -362,7 +376,7 @@ export class LocalAuthStrategy implements AuthStrategy {
 
       // Check if entry is expired
       if (entry.expiryDate && entry.expiryDate < new Date()) {
-        logger.debug(`LocalAuthStrategy: Entry ${request.identifier.value} expired`)
+        logger.debug(`${moduleName}: Entry ${truncateId(request.identifier.value)} expired`)
         return {
           expiryDate: entry.expiryDate,
           isOffline: false,
@@ -386,7 +400,7 @@ export class LocalAuthStrategy implements AuthStrategy {
       }
     } catch (error) {
       const errorMessage = getErrorMessage(error)
-      logger.error(`LocalAuthStrategy: Local auth list check failed: ${errorMessage}`)
+      logger.error(`${moduleName}: Local auth list check failed: ${errorMessage}`)
       throw new AuthenticationError(
         `Local auth list check failed: ${errorMessage}`,
         AuthErrorCode.LOCAL_LIST_ERROR,
@@ -434,7 +448,9 @@ export class LocalAuthStrategy implements AuthStrategy {
     request: AuthRequest,
     config: AuthConfiguration
   ): AuthorizationResult | undefined {
-    logger.debug(`LocalAuthStrategy: Applying offline fallback for ${request.identifier.value}`)
+    logger.debug(
+      `${moduleName}: Applying offline fallback for ${truncateId(request.identifier.value)}`
+    )
 
     // For transaction stops, always allow (safety requirement)
     if (request.context === AuthContext.TRANSACTION_STOP) {
@@ -493,8 +509,25 @@ export class LocalAuthStrategy implements AuthStrategy {
       case 'unauthorized':
         return AuthorizationStatus.INVALID
       default:
-        logger.warn(`LocalAuthStrategy: Unknown entry status: ${status}, defaulting to INVALID`)
+        logger.warn(`${moduleName}: Unknown entry status: ${status}, defaulting to INVALID`)
         return AuthorizationStatus.INVALID
     }
   }
+
+  /**
+   * Check whether a non-Accepted result should trigger post-authorize (remote re-auth).
+   *
+   * Per C10.FR.03, C12.FR.05, and C14.FR.03: when DisablePostAuthorize is explicitly false,
+   * non-Accepted tokens from cache or local list should be deferred to remote authorization.
+   * When DisablePostAuthorize is true or not configured, local results are returned as-is.
+   * @param result - Authorization result from cache or local list
+   * @param config - Authentication configuration with disablePostAuthorize setting
+   * @returns True if the result should be discarded to trigger remote re-authorization
+   */
+  private shouldTriggerPostAuthorize (
+    result: AuthorizationResult,
+    config: AuthConfiguration
+  ): boolean {
+    return result.status !== AuthorizationStatus.ACCEPTED && config.disablePostAuthorize === false
+  }
 }
index b80b06f99cbf54910a9d5b0d3077bd80c0c7d101..3e406e9532ea37140ffef305ce8523fa54e9395f 100644 (file)
@@ -15,6 +15,8 @@ import {
   IdentifierType,
 } from '../types/AuthTypes.js'
 
+const moduleName = 'RemoteAuthStrategy'
+
 /**
  * Remote Authentication Strategy
  *
@@ -65,7 +67,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
    */
   public addAdapter (version: OCPPVersion, adapter: OCPPAuthAdapter): void {
     this.adapters.set(version, adapter)
-    logger.debug(`RemoteAuthStrategy: Added OCPP ${version} adapter`)
+    logger.debug(`${moduleName}: Added OCPP ${version} adapter`)
   }
 
   /**
@@ -91,14 +93,14 @@ export class RemoteAuthStrategy implements AuthStrategy {
 
     try {
       logger.debug(
-        `RemoteAuthStrategy: Authenticating ${request.identifier.value.substring(0, 8)}... via CSMS for ${request.context}`
+        `${moduleName}: Authenticating ${request.identifier.value.substring(0, 8)}... via CSMS for ${request.context}`
       )
 
       // Get appropriate adapter for OCPP version
       const adapter = this.adapters.get(request.identifier.ocppVersion)
       if (!adapter) {
         logger.warn(
-          `RemoteAuthStrategy: No adapter available for OCPP version ${request.identifier.ocppVersion}`
+          `${moduleName}: No adapter available for OCPP version ${request.identifier.ocppVersion}`
         )
         return undefined
       }
@@ -106,7 +108,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
       // Check if remote service is available
       const isAvailable = await this.checkRemoteAvailability(adapter, config)
       if (!isAvailable) {
-        logger.debug('RemoteAuthStrategy: Remote service unavailable')
+        logger.debug(`${moduleName}: Remote service unavailable`)
         return undefined
       }
 
@@ -114,7 +116,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
       const result = await this.performRemoteAuthorization(request, adapter, config, startTime)
 
       if (result) {
-        logger.debug(`RemoteAuthStrategy: Remote authorization: ${result.status}`)
+        logger.debug(`${moduleName}: Remote authorization: ${result.status}`)
         this.stats.successfulRemoteAuth++
 
         // Check if identifier is in Local Auth List — do not cache (OCPP 1.6 §3.5.3)
@@ -124,7 +126,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
           const isInLocalList = await this.localAuthListManager.getEntry(request.identifier.value)
           if (isInLocalList) {
             logger.debug(
-              `RemoteAuthStrategy: Skipping cache for local list identifier: ${request.identifier.value.substring(0, 8)}...`
+              `${moduleName}: Skipping cache for local list identifier: ${request.identifier.value.substring(0, 8)}...`
             )
           } else {
             this.cacheResult(
@@ -147,7 +149,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
       }
 
       logger.debug(
-        `RemoteAuthStrategy: No remote authorization result for ${request.identifier.value.substring(0, 8)}...`
+        `${moduleName}: No remote authorization result for ${request.identifier.value.substring(0, 8)}...`
       )
       return undefined
     } catch (error) {
@@ -163,7 +165,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
       }
 
       const errorMessage = getErrorMessage(error)
-      logger.error(`RemoteAuthStrategy: Authentication error: ${errorMessage}`)
+      logger.error(`${moduleName}: Authentication error: ${errorMessage}`)
 
       // Don't rethrow - allow other strategies to handle
       return undefined
@@ -193,7 +195,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
    * Cleanup strategy resources
    */
   public cleanup (): void {
-    logger.info('RemoteAuthStrategy: Cleaning up...')
+    logger.info(`${moduleName}: Cleaning up...`)
 
     // Reset internal state
     this.isInitialized = false
@@ -208,7 +210,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
       totalResponseTimeMs: 0,
     }
 
-    logger.info('RemoteAuthStrategy: Cleanup completed')
+    logger.info(`${moduleName}: Cleanup completed`)
   }
 
   /**
@@ -257,11 +259,11 @@ export class RemoteAuthStrategy implements AuthStrategy {
    */
   public initialize (config: AuthConfiguration): void {
     try {
-      logger.info('RemoteAuthStrategy: Initializing...')
+      logger.info(`${moduleName}: Initializing...`)
 
       // Validate that we have at least one adapter
       if (this.adapters.size === 0) {
-        logger.warn('RemoteAuthStrategy: No OCPP adapters provided')
+        logger.warn(`${moduleName}: No OCPP adapters provided`)
       }
 
       // Validate adapter configurations
@@ -269,27 +271,27 @@ export class RemoteAuthStrategy implements AuthStrategy {
         try {
           const isValid = adapter.validateConfiguration(config)
           if (!isValid) {
-            logger.warn(`RemoteAuthStrategy: Invalid configuration for OCPP ${version}`)
+            logger.warn(`${moduleName}: Invalid configuration for OCPP ${version}`)
           } else {
-            logger.debug(`RemoteAuthStrategy: OCPP ${version} adapter configured`)
+            logger.debug(`${moduleName}: OCPP ${version} adapter configured`)
           }
         } catch (error) {
           const errorMessage = getErrorMessage(error)
           logger.error(
-            `RemoteAuthStrategy: Configuration validation failed for OCPP ${version}: ${errorMessage}`
+            `${moduleName}: Configuration validation failed for OCPP ${version}: ${errorMessage}`
           )
         }
       }
 
       if (this.authCache) {
-        logger.debug('RemoteAuthStrategy: Authorization cache available for result caching')
+        logger.debug(`${moduleName}: Authorization cache available for result caching`)
       }
 
       this.isInitialized = true
-      logger.info('RemoteAuthStrategy: Initialized successfully')
+      logger.info(`${moduleName}: Initialized successfully`)
     } catch (error) {
       const errorMessage = getErrorMessage(error)
-      logger.error(`RemoteAuthStrategy: Initialization failed: ${errorMessage}`)
+      logger.error(`${moduleName}: Initialization failed: ${errorMessage}`)
       throw new AuthenticationError(
         `Remote auth strategy initialization failed: ${errorMessage}`,
         AuthErrorCode.CONFIGURATION_ERROR,
@@ -306,7 +308,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
   public removeAdapter (version: OCPPVersion): boolean {
     const removed = this.adapters.delete(version)
     if (removed) {
-      logger.debug(`RemoteAuthStrategy: Removed OCPP ${version} adapter`)
+      logger.debug(`${moduleName}: Removed OCPP ${version} adapter`)
     }
     return removed
   }
@@ -374,7 +376,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
       identifierType === IdentifierType.NO_AUTHORIZATION ||
       identifierType === IdentifierType.CENTRAL
     ) {
-      logger.debug(`RemoteAuthStrategy: Skipping cache for ${identifierType} identifier type`)
+      logger.debug(`${moduleName}: Skipping cache for ${identifierType} identifier type`)
       return
     }
 
@@ -383,11 +385,11 @@ export class RemoteAuthStrategy implements AuthStrategy {
       const cacheTtl = ttl ?? result.cacheTtl ?? 300 // Default 5 minutes
       this.authCache.set(identifier, result, cacheTtl)
       logger.debug(
-        `RemoteAuthStrategy: Cached result for ${identifier.substring(0, 8)}... (TTL: ${String(cacheTtl)}s)`
+        `${moduleName}: Cached result for ${identifier.substring(0, 8)}... (TTL: ${String(cacheTtl)}s)`
       )
     } catch (error) {
       const errorMessage = getErrorMessage(error)
-      logger.error(`RemoteAuthStrategy: Failed to cache result: ${errorMessage}`)
+      logger.error(`${moduleName}: Failed to cache result: ${errorMessage}`)
       // Don't throw - caching is not critical for authentication
     }
   }
@@ -419,7 +421,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
       return result
     } catch (error) {
       const errorMessage = getErrorMessage(error)
-      logger.debug(`RemoteAuthStrategy: Remote availability check failed: ${errorMessage}`)
+      logger.debug(`${moduleName}: Remote availability check failed: ${errorMessage}`)
       return false
     }
   }
@@ -491,7 +493,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
 
       clearTimeout(timeoutHandle)
       logger.debug(
-        `RemoteAuthStrategy: Remote authorization completed in ${String(Date.now() - startTime)}ms`
+        `${moduleName}: Remote authorization completed in ${String(Date.now() - startTime)}ms`
       )
       return result
     } catch (error) {
index 355c4eb2f4a597f8ff33fde7e31aad378cf7610b..1fba4f6496b4358b44fa5f252c7cf2b41620ef5d 100644 (file)
@@ -3,6 +3,7 @@ import type { OCPPVersion } from '../../../../types/ocpp/OCPPVersion.js'
 
 import { OCPP16AuthorizationStatus } from '../../../../types/ocpp/1.6/Transaction.js'
 import {
+  OCPP20AuthorizationStatusEnumType,
   OCPP20IdTokenEnumType,
   RequestStartStopStatusEnumType,
 } from '../../../../types/ocpp/2.0/Transaction.js'
@@ -119,6 +120,15 @@ export interface AuthConfiguration extends JsonObject {
   /** Enable strict certificate validation (default: false) */
   certificateValidationStrict?: boolean
 
+  /**
+   * Disable post-authorize behavior for non-Accepted cached/local list tokens (OCPP 2.0).
+   * When true, non-Accepted tokens from cache or local list are accepted locally without
+   * triggering a remote AuthorizationRequest (C10.FR.03, C12.FR.05, C14.FR.03).
+   * When false, non-Accepted cached/local tokens trigger re-authorization via remote.
+   * When undefined, existing behavior is preserved (no filtering).
+   */
+  disablePostAuthorize?: boolean
+
   /** Enable local authorization list */
   localAuthListEnabled: boolean
 
@@ -375,6 +385,45 @@ export const mapOCPP16Status = (status: OCPP16AuthorizationStatus): Authorizatio
   }
 }
 
+/**
+ * Maps OCPP 2.0 authorization status enum to unified authorization status
+ * @param status - OCPP 2.0 authorization status
+ * @returns Unified authorization status
+ * @example
+ * ```typescript
+ * const unifiedStatus = mapOCPP20AuthorizationStatus(OCPP20AuthorizationStatusEnumType.Accepted)
+ * // Returns: AuthorizationStatus.ACCEPTED
+ * ```
+ */
+export const mapOCPP20AuthorizationStatus = (
+  status: OCPP20AuthorizationStatusEnumType
+): AuthorizationStatus => {
+  switch (status) {
+    case OCPP20AuthorizationStatusEnumType.Accepted:
+      return AuthorizationStatus.ACCEPTED
+    case OCPP20AuthorizationStatusEnumType.Blocked:
+      return AuthorizationStatus.BLOCKED
+    case OCPP20AuthorizationStatusEnumType.ConcurrentTx:
+      return AuthorizationStatus.CONCURRENT_TX
+    case OCPP20AuthorizationStatusEnumType.Expired:
+      return AuthorizationStatus.EXPIRED
+    case OCPP20AuthorizationStatusEnumType.Invalid:
+      return AuthorizationStatus.INVALID
+    case OCPP20AuthorizationStatusEnumType.NoCredit:
+      return AuthorizationStatus.NO_CREDIT
+    case OCPP20AuthorizationStatusEnumType.NotAllowedTypeEVSE:
+      return AuthorizationStatus.NOT_ALLOWED_TYPE_EVSE
+    case OCPP20AuthorizationStatusEnumType.NotAtThisLocation:
+      return AuthorizationStatus.NOT_AT_THIS_LOCATION
+    case OCPP20AuthorizationStatusEnumType.NotAtThisTime:
+      return AuthorizationStatus.NOT_AT_THIS_TIME
+    case OCPP20AuthorizationStatusEnumType.Unknown:
+      return AuthorizationStatus.UNKNOWN
+    default:
+      return AuthorizationStatus.INVALID
+  }
+}
+
 /**
  * Maps OCPP 2.0 token type to unified identifier type
  * @param type - OCPP 2.0 token type
index fa6607eacd503679b36cb9042e4c25b3b88b1e42..7f9e5bffb5b1b45db97e6090bb3b8f7e0d32f384 100644 (file)
@@ -1,6 +1,8 @@
 import { logger } from '../../../../utils/index.js'
 import { type AuthConfiguration, AuthenticationError, AuthErrorCode } from '../types/AuthTypes.js'
 
+const moduleName = 'AuthConfigValidator'
+
 /**
  * Warn if no authentication method is enabled in the configuration.
  * @param config - Authentication configuration to check
@@ -14,7 +16,7 @@ function checkAuthMethodsEnabled (config: AuthConfiguration): void {
 
   if (!hasLocalList && !hasCache && !hasRemote && !hasCertificate && !hasOffline) {
     logger.warn(
-      'AuthConfigValidator: No authentication method is enabled. All authorization requests will fail unless at least one method is enabled.'
+      `${moduleName}: No authentication method is enabled. All authorization requests will fail unless at least one method is enabled.`
     )
   }
 
@@ -26,9 +28,7 @@ function checkAuthMethodsEnabled (config: AuthConfiguration): void {
   if (hasOffline) enabledMethods.push('offline')
 
   if (enabledMethods.length > 0) {
-    logger.debug(
-      `AuthConfigValidator: Enabled authentication methods: ${enabledMethods.join(', ')}`
-    )
+    logger.debug(`${moduleName}: Enabled authentication methods: ${enabledMethods.join(', ')}`)
   }
 }
 
@@ -46,7 +46,7 @@ function validate (config: AuthConfiguration): void {
   validateOfflineConfig(config)
   checkAuthMethodsEnabled(config)
 
-  logger.debug('AuthConfigValidator: Configuration validated successfully')
+  logger.debug(`${moduleName}: Configuration validated successfully`)
 }
 
 /**
@@ -71,13 +71,13 @@ function validateCacheConfig (config: AuthConfiguration): void {
 
     if (config.authorizationCacheLifetime < 60) {
       logger.warn(
-        `AuthConfigValidator: authorizationCacheLifetime is very short (${String(config.authorizationCacheLifetime)}s). Consider using at least 60s for efficiency.`
+        `${moduleName}: authorizationCacheLifetime is very short (${String(config.authorizationCacheLifetime)}s). Consider using at least 60s for efficiency.`
       )
     }
 
     if (config.authorizationCacheLifetime > 86400) {
       logger.warn(
-        `AuthConfigValidator: authorizationCacheLifetime is very long (${String(config.authorizationCacheLifetime)}s). This may lead to stale authorizations.`
+        `${moduleName}: authorizationCacheLifetime is very long (${String(config.authorizationCacheLifetime)}s). This may lead to stale authorizations.`
       )
     }
   }
@@ -99,7 +99,7 @@ function validateCacheConfig (config: AuthConfiguration): void {
 
     if (config.maxCacheEntries < 10) {
       logger.warn(
-        `AuthConfigValidator: maxCacheEntries is very small (${String(config.maxCacheEntries)}). Cache may be ineffective with frequent evictions.`
+        `${moduleName}: maxCacheEntries is very small (${String(config.maxCacheEntries)}). Cache may be ineffective with frequent evictions.`
       )
     }
   }
@@ -112,7 +112,7 @@ function validateCacheConfig (config: AuthConfiguration): void {
 function validateOfflineConfig (config: AuthConfiguration): void {
   if (config.allowOfflineTxForUnknownId && !config.offlineAuthorizationEnabled) {
     logger.warn(
-      'AuthConfigValidator: allowOfflineTxForUnknownId is true but offlineAuthorizationEnabled is false. Unknown IDs will not be authorized.'
+      `${moduleName}: allowOfflineTxForUnknownId is true but offlineAuthorizationEnabled is false. Unknown IDs will not be authorized.`
     )
   }
 
@@ -122,7 +122,7 @@ function validateOfflineConfig (config: AuthConfiguration): void {
     config.unknownIdAuthorization
   ) {
     logger.debug(
-      `AuthConfigValidator: Offline mode enabled with unknownIdAuthorization=${config.unknownIdAuthorization}`
+      `${moduleName}: Offline mode enabled with unknownIdAuthorization=${config.unknownIdAuthorization}`
     )
   }
 }
@@ -148,13 +148,13 @@ function validateTimeout (config: AuthConfiguration): void {
 
   if (config.authorizationTimeout < 5) {
     logger.warn(
-      `AuthConfigValidator: authorizationTimeout is very short (${String(config.authorizationTimeout)}s). This may cause premature timeouts.`
+      `${moduleName}: authorizationTimeout is very short (${String(config.authorizationTimeout)}s). This may cause premature timeouts.`
     )
   }
 
   if (config.authorizationTimeout > 60) {
     logger.warn(
-      `AuthConfigValidator: authorizationTimeout is very long (${String(config.authorizationTimeout)}s). Users may experience long waits.`
+      `${moduleName}: authorizationTimeout is very long (${String(config.authorizationTimeout)}s). Users may experience long waits.`
     )
   }
 }
index 4fdeae1cbee7e3eb1ddcea122f67f0521373d099..7b272de22f2f31fd58c61771d5364bc086c224a6 100644 (file)
@@ -34,6 +34,7 @@ export interface ConnectorStatus {
    * that occurs after the EV has connected.
    */
   transactionEvseSent?: boolean
+  transactionGroupIdToken?: string
   transactionId?: number | string
   transactionIdTag?: string
   /**
index bc723324ab6f642ec37408c1c4b73e9e6c8d416a..876da768cc10b8a55309b5207859e8eb6cb72699 100644 (file)
@@ -482,3 +482,10 @@ export const queueMicrotaskErrorThrowing = (error: Error): void => {
     throw error
   })
 }
+
+export const truncateId = (identifier: string, maxLen = 8): string => {
+  if (identifier.length <= maxLen) {
+    return identifier
+  }
+  return `${identifier.slice(0, maxLen)}...`
+}
index e708405bb5cdfc72cd37e5f46b15bc21d7e0fc65..a36d9451ea7047984373b19f1fe16b86701eb423 100644 (file)
@@ -58,6 +58,7 @@ export {
   roundTo,
   secureRandom,
   sleep,
+  truncateId,
   validateIdentifierString,
   validateUUID,
 } from './Utils.js'
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GroupIdStop.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GroupIdStop.test.ts
new file mode 100644 (file)
index 0000000..7631280
--- /dev/null
@@ -0,0 +1,179 @@
+/**
+ * @file Tests for OCPP 2.0 GroupId-based stop transaction authorization
+ * @description Unit tests for C01.FR.03, C09.FR.03, C09.FR.07 conformance
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type { OCPP20RequestStartTransactionRequest } from '../../../../src/types/index.js'
+
+import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceFactory.js'
+import { RequestStartStopStatusEnumType } from '../../../../src/types/index.js'
+import { OCPP20IdTokenEnumType } from '../../../../src/types/ocpp/2.0/Transaction.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockAuthService } from '../auth/helpers/MockFactories.js'
+import {
+  createOCPP20ListenerStation,
+  resetConnectorTransactionState,
+  resetLimits,
+  resetReportingValueSize,
+} from './OCPP20TestUtils.js'
+
+const GROUP_ID_TOKEN = 'GROUP_ALPHA'
+const DIFFERENT_GROUP_ID_TOKEN = 'GROUP_BETA'
+const START_TOKEN = 'START_USER_001'
+const STOP_TOKEN_SAME_GROUP = 'STOP_USER_002'
+const STOP_TOKEN_DIFFERENT_GROUP = 'STOP_USER_003'
+
+await describe('C09 - GroupId-based Stop Transaction Authorization', async () => {
+  let mockStation: ChargingStation
+  let incomingRequestService: OCPP20IncomingRequestService
+  let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+  beforeEach(() => {
+    const { station } = createOCPP20ListenerStation(TEST_CHARGING_STATION_BASE_NAME)
+    mockStation = station
+    incomingRequestService = new OCPP20IncomingRequestService()
+    testableService = createTestableIncomingRequestService(incomingRequestService)
+    const stationId = mockStation.stationInfo?.chargingStationId ?? 'unknown'
+    OCPPAuthServiceFactory.setInstanceForTesting(stationId, createMockAuthService())
+    resetLimits(mockStation)
+    resetReportingValueSize(mockStation)
+    resetConnectorTransactionState(mockStation)
+  })
+
+  afterEach(() => {
+    standardCleanup()
+    OCPPAuthServiceFactory.clearAllInstances()
+  })
+
+  /**
+   * Starts a transaction with an optional group ID token on the given EVSE.
+   * @param station - charging station instance
+   * @param evseId - EVSE identifier
+   * @param remoteStartId - remote start identifier
+   * @param idToken - token used to start
+   * @param groupIdToken - optional group token
+   * @returns transaction ID
+   */
+  async function startTransactionWithGroup (
+    station: ChargingStation,
+    evseId = 1,
+    remoteStartId = 1,
+    idToken = START_TOKEN,
+    groupIdToken?: string
+  ): Promise<string> {
+    const startRequest: OCPP20RequestStartTransactionRequest = {
+      evseId,
+      groupIdToken:
+        groupIdToken != null
+          ? { idToken: groupIdToken, type: OCPP20IdTokenEnumType.Central }
+          : undefined,
+      idToken: {
+        idToken,
+        type: OCPP20IdTokenEnumType.ISO14443,
+      },
+      remoteStartId,
+    }
+
+    const startResponse = await testableService.handleRequestStartTransaction(station, startRequest)
+
+    assert.strictEqual(startResponse.status, RequestStartStopStatusEnumType.Accepted)
+    assert.notStrictEqual(startResponse.transactionId, undefined)
+    return startResponse.transactionId as string
+  }
+
+  await it('C09.FR.03/C09.FR.07 - should authorize stop by same GroupIdToken without AuthorizationRequest', async () => {
+    await startTransactionWithGroup(mockStation, 1, 100, START_TOKEN, GROUP_ID_TOKEN)
+
+    const connectorStatus = mockStation.getConnectorStatus(1)
+    assert.notStrictEqual(connectorStatus, undefined)
+    if (connectorStatus == null) {
+      assert.fail('Expected connectorStatus to be defined')
+    }
+    assert.strictEqual(connectorStatus.transactionGroupIdToken, GROUP_ID_TOKEN)
+    assert.strictEqual(connectorStatus.transactionStarted, true)
+
+    const isAuthorized = testableService.isAuthorizedToStopTransaction(
+      mockStation,
+      1,
+      { idToken: STOP_TOKEN_SAME_GROUP, type: OCPP20IdTokenEnumType.ISO14443 },
+      { idToken: GROUP_ID_TOKEN, type: OCPP20IdTokenEnumType.Central }
+    )
+
+    assert.strictEqual(isAuthorized, true)
+  })
+
+  await it('should NOT authorize stop by different GroupIdToken', async () => {
+    await startTransactionWithGroup(mockStation, 1, 300, START_TOKEN, GROUP_ID_TOKEN)
+
+    const isAuthorized = testableService.isAuthorizedToStopTransaction(
+      mockStation,
+      1,
+      { idToken: STOP_TOKEN_DIFFERENT_GROUP, type: OCPP20IdTokenEnumType.ISO14443 },
+      { idToken: DIFFERENT_GROUP_ID_TOKEN, type: OCPP20IdTokenEnumType.Central }
+    )
+
+    assert.strictEqual(isAuthorized, false)
+  })
+
+  // FR: C01.FR.03(a)
+  await it('should authorize stop by same idToken as start without GroupIdToken', async () => {
+    await startTransactionWithGroup(mockStation, 1, 400, START_TOKEN)
+
+    const isAuthorized = testableService.isAuthorizedToStopTransaction(mockStation, 1, {
+      idToken: START_TOKEN,
+      type: OCPP20IdTokenEnumType.ISO14443,
+    })
+
+    assert.strictEqual(isAuthorized, true)
+  })
+
+  await it('should NOT authorize stop when no active transaction on connector', () => {
+    const isAuthorized = testableService.isAuthorizedToStopTransaction(
+      mockStation,
+      1,
+      { idToken: START_TOKEN, type: OCPP20IdTokenEnumType.ISO14443 },
+      { idToken: GROUP_ID_TOKEN, type: OCPP20IdTokenEnumType.Central }
+    )
+
+    assert.strictEqual(isAuthorized, false)
+  })
+
+  await it('should NOT authorize stop when presented GroupIdToken is undefined but start had one', async () => {
+    await startTransactionWithGroup(mockStation, 1, 500, START_TOKEN, GROUP_ID_TOKEN)
+
+    const isAuthorized = testableService.isAuthorizedToStopTransaction(mockStation, 1, {
+      idToken: STOP_TOKEN_SAME_GROUP,
+      type: OCPP20IdTokenEnumType.ISO14443,
+    })
+
+    assert.strictEqual(isAuthorized, false)
+  })
+
+  await it('should store transactionGroupIdToken on connector during start', async () => {
+    await startTransactionWithGroup(mockStation, 1, 600, START_TOKEN, GROUP_ID_TOKEN)
+
+    const connectorStatus = mockStation.getConnectorStatus(1)
+    if (connectorStatus == null) {
+      assert.fail('Expected connectorStatus to be defined')
+    }
+    assert.strictEqual(connectorStatus.transactionGroupIdToken, GROUP_ID_TOKEN)
+    assert.strictEqual(connectorStatus.transactionIdTag, START_TOKEN)
+  })
+
+  await it('should not store transactionGroupIdToken when start has no groupIdToken', async () => {
+    await startTransactionWithGroup(mockStation, 1, 700, START_TOKEN)
+
+    const connectorStatus = mockStation.getConnectorStatus(1)
+    if (connectorStatus == null) {
+      assert.fail('Expected connectorStatus to be defined')
+    }
+    assert.strictEqual(connectorStatus.transactionGroupIdToken, undefined)
+  })
+})
diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-MasterPass.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-MasterPass.test.ts
new file mode 100644 (file)
index 0000000..3e0ad1c
--- /dev/null
@@ -0,0 +1,215 @@
+/**
+ * @file Tests for OCPP20IncomingRequestService MasterPassGroupId check
+ * @description Unit tests for OCPP 2.0 MasterPassGroupId authorization (C12.FR.09)
+ */
+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 { OCPP20RequestStartTransactionRequest } from '../../../../src/types/index.js'
+import type { OCPP20GetVariableDataType } from '../../../../src/types/index.js'
+
+import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js'
+import { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceFactory.js'
+import {
+  GetVariableStatusEnumType,
+  OCPPVersion,
+  RequestStartStopStatusEnumType,
+} from '../../../../src/types/index.js'
+import { OCPP20IdTokenEnumType } from '../../../../src/types/ocpp/2.0/Transaction.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+import { createMockAuthService } from '../auth/helpers/MockFactories.js'
+import {
+  resetConnectorTransactionState,
+  resetLimits,
+  resetReportingValueSize,
+} from './OCPP20TestUtils.js'
+
+await describe('C12.FR.09 - MasterPassGroupId Check', async () => {
+  let mockStation: ChargingStation
+  let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+  beforeEach(() => {
+    const { station } = createMockChargingStation({
+      baseName: TEST_CHARGING_STATION_BASE_NAME,
+      connectorsCount: 3,
+      evseConfiguration: { evsesCount: 3 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      ocppRequestService: {
+        requestHandler: async () => Promise.resolve({}),
+      },
+      stationInfo: {
+        ocppStrictCompliance: false,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    mockStation = station
+    const incomingRequestService = new OCPP20IncomingRequestService()
+    testableService = createTestableIncomingRequestService(incomingRequestService)
+    const stationId = mockStation.stationInfo?.chargingStationId ?? 'unknown'
+    OCPPAuthServiceFactory.setInstanceForTesting(stationId, createMockAuthService())
+    resetConnectorTransactionState(mockStation)
+    resetLimits(mockStation)
+    resetReportingValueSize(mockStation)
+  })
+
+  afterEach(() => {
+    OCPP20VariableManager.getInstance().resetRuntimeOverrides()
+    OCPP20VariableManager.getInstance().invalidateMappingsCache()
+    standardCleanup()
+    OCPPAuthServiceFactory.clearAllInstances()
+  })
+
+  await it('C12.FR.09 - should reject start transaction when groupIdToken matches MasterPassGroupId', async () => {
+    const masterPassGroupId = 'MASTER_GROUP_1'
+
+    const originalGetVariables = OCPP20VariableManager.getInstance().getVariables.bind(
+      OCPP20VariableManager.getInstance()
+    )
+    mock.method(
+      OCPP20VariableManager.getInstance(),
+      'getVariables',
+      (station: ChargingStation, requests: OCPP20GetVariableDataType[]) => {
+        const results = originalGetVariables(station, requests)
+        for (let i = 0; i < (requests as { variable?: { name?: string } }[]).length; i++) {
+          const req = (requests as { variable?: { name?: string } }[])[i]
+          if (req.variable?.name === 'MasterPassGroupId') {
+            results[i] = {
+              ...results[i],
+              attributeStatus: GetVariableStatusEnumType.Accepted,
+              attributeValue: masterPassGroupId,
+            }
+          }
+        }
+        return results
+      }
+    )
+
+    const request: OCPP20RequestStartTransactionRequest = {
+      evseId: 1,
+      groupIdToken: {
+        idToken: masterPassGroupId,
+        type: OCPP20IdTokenEnumType.Central,
+      },
+      idToken: {
+        idToken: 'SOME_USER_TOKEN',
+        type: OCPP20IdTokenEnumType.ISO14443,
+      },
+      remoteStartId: 1,
+    }
+
+    const response = await testableService.handleRequestStartTransaction(mockStation, request)
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(response.status, RequestStartStopStatusEnumType.Rejected)
+  })
+
+  await it('C12.FR.09 - should be no-op when MasterPassGroupId not configured', async () => {
+    const request: OCPP20RequestStartTransactionRequest = {
+      evseId: 1,
+      idToken: {
+        idToken: 'VALID_TOKEN_123',
+        type: OCPP20IdTokenEnumType.ISO14443,
+      },
+      remoteStartId: 1,
+    }
+
+    const response = await testableService.handleRequestStartTransaction(mockStation, request)
+
+    assert.notStrictEqual(response, undefined)
+    assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted)
+    assert.notStrictEqual(response.transactionId, undefined)
+  })
+
+  await it('C12.FR.09 - should allow start when groupIdToken does not match MasterPassGroupId', async () => {
+    const masterPassGroupId = 'MASTER_GROUP_1'
+
+    const originalGetVariables = OCPP20VariableManager.getInstance().getVariables.bind(
+      OCPP20VariableManager.getInstance()
+    )
+    mock.method(
+      OCPP20VariableManager.getInstance(),
+      'getVariables',
+      (station: ChargingStation, requests: OCPP20GetVariableDataType[]) => {
+        const results = originalGetVariables(station, requests)
+        for (let i = 0; i < (requests as { variable?: { name?: string } }[]).length; i++) {
+          const req = (requests as { variable?: { name?: string } }[])[i]
+          if (req.variable?.name === 'MasterPassGroupId') {
+            results[i] = {
+              ...results[i],
+              attributeStatus: GetVariableStatusEnumType.Accepted,
+              attributeValue: masterPassGroupId,
+            }
+          }
+        }
+        return results
+      }
+    )
+
+    const request: OCPP20RequestStartTransactionRequest = {
+      evseId: 1,
+      groupIdToken: {
+        idToken: 'DIFFERENT_GROUP',
+        type: OCPP20IdTokenEnumType.Central,
+      },
+      idToken: {
+        idToken: 'SOME_USER_TOKEN',
+        type: OCPP20IdTokenEnumType.ISO14443,
+      },
+      remoteStartId: 1,
+    }
+
+    const response = await testableService.handleRequestStartTransaction(mockStation, request)
+
+    assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted)
+  })
+
+  await it('C12.FR.09 - should not reject when idToken matches MasterPassGroupId but groupIdToken does not', async () => {
+    const masterPassGroupId = 'MASTER_GROUP_1'
+
+    const originalGetVariables = OCPP20VariableManager.getInstance().getVariables.bind(
+      OCPP20VariableManager.getInstance()
+    )
+    mock.method(
+      OCPP20VariableManager.getInstance(),
+      'getVariables',
+      (station: ChargingStation, requests: OCPP20GetVariableDataType[]) => {
+        const results = originalGetVariables(station, requests)
+        for (let i = 0; i < (requests as { variable?: { name?: string } }[]).length; i++) {
+          const req = (requests as { variable?: { name?: string } }[])[i]
+          if (req.variable?.name === 'MasterPassGroupId') {
+            results[i] = {
+              ...results[i],
+              attributeStatus: GetVariableStatusEnumType.Accepted,
+              attributeValue: masterPassGroupId,
+            }
+          }
+        }
+        return results
+      }
+    )
+
+    const request: OCPP20RequestStartTransactionRequest = {
+      evseId: 1,
+      groupIdToken: {
+        idToken: 'DIFFERENT_GROUP',
+        type: OCPP20IdTokenEnumType.Central,
+      },
+      idToken: {
+        idToken: masterPassGroupId,
+        type: OCPP20IdTokenEnumType.ISO14443,
+      },
+      remoteStartId: 1,
+    }
+
+    const response = await testableService.handleRequestStartTransaction(mockStation, request)
+
+    assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted)
+  })
+})
index 70c99756030b55d87aaf5e01e0d61adbbf31e770..b3212a8bb69ed409ad2d84b9551c81626440bf64 100644 (file)
@@ -249,7 +249,7 @@ await describe('F03 - Remote Stop Transaction', async () => {
     await it(
       'should call requestStopTransaction when response is Accepted',
       {
-        skip: process.platform === 'darwin' && process.versions.node.startsWith('22.'),
+        skip: process.versions.node.startsWith('22.'),
       },
       async () => {
         const transactionId = await startTransaction(listenerStation, 1, 100)
diff --git a/tests/charging-station/ocpp/2.0/OCPP20ResponseService-CacheUpdate.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ResponseService-CacheUpdate.test.ts
new file mode 100644 (file)
index 0000000..a3cd6f2
--- /dev/null
@@ -0,0 +1,183 @@
+/**
+ * @file Tests for OCPP20ResponseService cache update on TransactionEventResponse
+ * @description Unit tests for auth cache auto-update from TransactionEventResponse idTokenInfo
+ * per OCPP 2.0.1 C10.FR.01/04/05, C12.FR.06, C02.FR.03, C03.FR.02
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type { AuthCache } from '../../../../src/charging-station/ocpp/auth/interfaces/OCPPAuthService.js'
+import type { LocalAuthStrategy } from '../../../../src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.js'
+
+import { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceFactory.js'
+import { OCPPAuthServiceImpl } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceImpl.js'
+import {
+  AuthorizationStatus,
+  IdentifierType,
+} from '../../../../src/charging-station/ocpp/auth/types/AuthTypes.js'
+import { OCPP20AuthorizationStatusEnumType } from '../../../../src/types/ocpp/2.0/Transaction.js'
+import { OCPPVersion } from '../../../../src/types/ocpp/OCPPVersion.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+const TEST_IDENTIFIER = 'TEST_RFID_TOKEN_001'
+const TEST_STATION_ID = 'CS_CACHE_UPDATE_TEST'
+
+await describe('C10 - TransactionEventResponse Cache Update', async () => {
+  let station: ChargingStation
+  let authService: OCPPAuthServiceImpl
+  let authCache: AuthCache
+
+  beforeEach(async () => {
+    const { station: mockStation } = createMockChargingStation({
+      baseName: TEST_STATION_ID,
+      connectorsCount: 1,
+      stationInfo: {
+        chargingStationId: TEST_STATION_ID,
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+    })
+    station = mockStation
+
+    authService = new OCPPAuthServiceImpl(station)
+    await authService.initialize()
+
+    const localStrategy = authService.getStrategy('local') as LocalAuthStrategy | undefined
+    const cache = localStrategy?.getAuthCache()
+    assert.ok(cache != null, 'Auth cache must be available after initialization')
+    authCache = cache
+  })
+
+  afterEach(() => {
+    OCPPAuthServiceFactory.clearAllInstances()
+    standardCleanup()
+  })
+
+  await it('C10.FR.05 - should update cache on TransactionEventResponse with Accepted idTokenInfo', () => {
+    // Arrange
+    const idTokenInfo = {
+      status: OCPP20AuthorizationStatusEnumType.Accepted,
+    }
+
+    // Act
+    authService.updateCacheEntry(TEST_IDENTIFIER, idTokenInfo, IdentifierType.ISO14443)
+
+    // Assert
+    const cached = authCache.get(TEST_IDENTIFIER)
+    assert.ok(cached != null, 'Cache entry should exist')
+    assert.strictEqual(cached.status, AuthorizationStatus.ACCEPTED)
+  })
+
+  await it('C10.FR.09 - should use cacheExpiryDateTime as TTL when present in idTokenInfo', () => {
+    // Arrange — expiry 600 seconds from now
+    const futureDate = new Date(Date.now() + 600_000)
+    const idTokenInfo = {
+      cacheExpiryDateTime: futureDate,
+      status: OCPP20AuthorizationStatusEnumType.Accepted,
+    }
+
+    // Act
+    authService.updateCacheEntry(TEST_IDENTIFIER, idTokenInfo, IdentifierType.ISO14443)
+
+    // Assert — entry is cached (TTL is explicit, checked via presence)
+    const cached = authCache.get(TEST_IDENTIFIER)
+    assert.ok(cached != null, 'Cache entry should exist with explicit TTL')
+    assert.strictEqual(cached.status, AuthorizationStatus.ACCEPTED)
+  })
+
+  await it('C10.FR.08 - should use AuthCacheLifeTime as TTL when cacheExpiryDateTime absent', () => {
+    // Arrange — no cacheExpiryDateTime
+    const idTokenInfo = {
+      status: OCPP20AuthorizationStatusEnumType.Accepted,
+    }
+
+    // Act
+    authService.updateCacheEntry(TEST_IDENTIFIER, idTokenInfo, IdentifierType.ISO14443)
+
+    // Assert — entry is cached (uses config.authorizationCacheLifetime as default TTL)
+    const cached = authCache.get(TEST_IDENTIFIER)
+    assert.ok(cached != null, 'Cache entry should exist with default TTL')
+    assert.strictEqual(cached.status, AuthorizationStatus.ACCEPTED)
+  })
+
+  await it('C02.FR.03 - should NOT cache NoAuthorization token type', () => {
+    // Arrange
+    const idTokenInfo = {
+      status: OCPP20AuthorizationStatusEnumType.Accepted,
+    }
+
+    // Act
+    authService.updateCacheEntry('', idTokenInfo, IdentifierType.NO_AUTHORIZATION)
+
+    // Assert
+    const cached = authCache.get('')
+    assert.strictEqual(cached, undefined, 'NoAuthorization tokens must not be cached')
+  })
+
+  await it('C03.FR.02 - should NOT cache Central token type', () => {
+    // Arrange
+    const idTokenInfo = {
+      status: OCPP20AuthorizationStatusEnumType.Accepted,
+    }
+
+    // Act
+    authService.updateCacheEntry('CENTRAL_TOKEN_001', idTokenInfo, IdentifierType.CENTRAL)
+
+    // Assert
+    const cached = authCache.get('CENTRAL_TOKEN_001')
+    assert.strictEqual(cached, undefined, 'Central tokens must not be cached')
+  })
+
+  await it('C10.FR.01 - should cache non-Accepted status (Blocked, Expired, etc.)', () => {
+    // Arrange — multiple non-Accepted statuses per C10.FR.01: cache ALL statuses
+    const blockedInfo = {
+      status: OCPP20AuthorizationStatusEnumType.Blocked,
+    }
+    const expiredInfo = {
+      status: OCPP20AuthorizationStatusEnumType.Expired,
+    }
+
+    // Act
+    authService.updateCacheEntry('BLOCKED_TOKEN', blockedInfo, IdentifierType.ISO14443)
+    authService.updateCacheEntry('EXPIRED_TOKEN', expiredInfo, IdentifierType.ISO14443)
+
+    // Assert
+    const cachedBlocked = authCache.get('BLOCKED_TOKEN')
+    assert.ok(cachedBlocked != null, 'Blocked status should be cached')
+    assert.strictEqual(cachedBlocked.status, AuthorizationStatus.BLOCKED)
+
+    const cachedExpired = authCache.get('EXPIRED_TOKEN')
+    assert.ok(cachedExpired != null, 'Expired status should be cached')
+    assert.strictEqual(cachedExpired.status, AuthorizationStatus.EXPIRED)
+  })
+
+  await it('should not update cache when authorizationCacheEnabled is false', async () => {
+    // Arrange — create service with cache disabled
+    const { station: disabledStation } = createMockChargingStation({
+      baseName: 'CS_CACHE_DISABLED',
+      connectorsCount: 1,
+      stationInfo: {
+        chargingStationId: 'CS_CACHE_DISABLED',
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+    })
+    const disabledService = new OCPPAuthServiceImpl(disabledStation)
+    await disabledService.initialize()
+    disabledService.updateConfiguration({ authorizationCacheEnabled: false })
+
+    const idTokenInfo = {
+      status: OCPP20AuthorizationStatusEnumType.Accepted,
+    }
+
+    // Act
+    disabledService.updateCacheEntry(TEST_IDENTIFIER, idTokenInfo, IdentifierType.ISO14443)
+
+    // Assert — cache should not have been written to
+    const localStrategy = disabledService.getStrategy('local') as LocalAuthStrategy | undefined
+    const cache = localStrategy?.getAuthCache()
+    const cached = cache?.get(TEST_IDENTIFIER)
+    assert.strictEqual(cached, undefined, 'Cache entry should not exist when cache is disabled')
+  })
+})
index 16596814b64133fcfbc33e4cb5ccd99d550a56fe..61625ea0b7b24a9614a29f7d2033d5cbe537ec54 100644 (file)
@@ -231,6 +231,7 @@ export function resetConnectorTransactionState (chargingStation: ChargingStation
         connectorStatus.transactionStarted = false
         connectorStatus.transactionId = undefined
         connectorStatus.transactionIdTag = undefined
+        connectorStatus.transactionGroupIdToken = undefined
         connectorStatus.transactionStart = undefined
         connectorStatus.transactionEnergyActiveImportRegisterValue = 0
         connectorStatus.remoteStartId = undefined
@@ -244,6 +245,7 @@ export function resetConnectorTransactionState (chargingStation: ChargingStation
       connectorStatus.transactionStarted = false
       connectorStatus.transactionId = undefined
       connectorStatus.transactionIdTag = undefined
+      connectorStatus.transactionGroupIdToken = undefined
       connectorStatus.transactionStart = undefined
       connectorStatus.transactionEnergyActiveImportRegisterValue = 0
       connectorStatus.remoteStartId = undefined
index 69b44649504e4e9f20cb364960ba2dca63dc093b..c490e184bbdb3f7b7c03af2b61fcd8927cd939ad 100644 (file)
@@ -7,14 +7,12 @@ import { afterEach, beforeEach, describe, it } from 'node:test'
 
 import type { AuthorizationResult } from '../../../../../src/charging-station/ocpp/auth/types/AuthTypes.js'
 
-import {
-  InMemoryAuthCache,
-  truncateId,
-} from '../../../../../src/charging-station/ocpp/auth/cache/InMemoryAuthCache.js'
+import { InMemoryAuthCache } from '../../../../../src/charging-station/ocpp/auth/cache/InMemoryAuthCache.js'
 import {
   AuthenticationMethod,
   AuthorizationStatus,
 } from '../../../../../src/charging-station/ocpp/auth/types/AuthTypes.js'
+import { truncateId } from '../../../../../src/utils/index.js'
 import { standardCleanup, withMockTimers } from '../../../../helpers/TestLifecycleHelpers.js'
 import { createMockAuthorizationResult } from '../helpers/MockFactories.js'
 
diff --git a/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy-DisablePostAuthorize.test.ts b/tests/charging-station/ocpp/auth/strategies/LocalAuthStrategy-DisablePostAuthorize.test.ts
new file mode 100644 (file)
index 0000000..c383a49
--- /dev/null
@@ -0,0 +1,160 @@
+/**
+ * @file Tests for LocalAuthStrategy DisablePostAuthorize behavior
+ * @description Tests for C10.FR.03, C12.FR.05, C14.FR.03 conformance
+ */
+import assert from 'node:assert/strict'
+import { afterEach, describe, it } from 'node:test'
+
+import { LocalAuthStrategy } from '../../../../../src/charging-station/ocpp/auth/strategies/LocalAuthStrategy.js'
+import {
+  AuthenticationMethod,
+  AuthorizationStatus,
+  IdentifierType,
+} from '../../../../../src/charging-station/ocpp/auth/types/AuthTypes.js'
+import { OCPPVersion } from '../../../../../src/types/ocpp/OCPPVersion.js'
+import { standardCleanup } from '../../../../helpers/TestLifecycleHelpers.js'
+import {
+  createMockAuthCache,
+  createMockAuthorizationResult,
+  createMockAuthRequest,
+  createMockIdentifier,
+  createMockLocalAuthListManager,
+  createTestAuthConfig,
+} from '../helpers/MockFactories.js'
+
+await describe('LocalAuthStrategy - DisablePostAuthorize', async () => {
+  let strategy: LocalAuthStrategy
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('C10.FR.03 - cache post-authorize', async () => {
+    await it('should accept non-Accepted cached token without re-auth when DisablePostAuthorize=true', async () => {
+      // Arrange
+      const blockedResult = createMockAuthorizationResult({
+        method: AuthenticationMethod.CACHE,
+        status: AuthorizationStatus.BLOCKED,
+      })
+      const mockAuthCache = createMockAuthCache({
+        get: () => blockedResult,
+      })
+      strategy = new LocalAuthStrategy(undefined, mockAuthCache)
+      const config = createTestAuthConfig({
+        authorizationCacheEnabled: true,
+        disablePostAuthorize: true,
+      })
+      strategy.initialize(config)
+      const request = createMockAuthRequest({
+        identifier: createMockIdentifier(
+          OCPPVersion.VERSION_20,
+          'BLOCKED-TAG',
+          IdentifierType.ISO14443
+        ),
+      })
+
+      // Act
+      const result = await strategy.authenticate(request, config)
+
+      // Assert
+      assert.notStrictEqual(result, undefined)
+      assert.strictEqual(result?.status, AuthorizationStatus.BLOCKED)
+      assert.strictEqual(result.method, AuthenticationMethod.CACHE)
+    })
+
+    await it('should trigger re-auth for non-Accepted cached token when DisablePostAuthorize=false', async () => {
+      // Arrange
+      const blockedResult = createMockAuthorizationResult({
+        method: AuthenticationMethod.CACHE,
+        status: AuthorizationStatus.BLOCKED,
+      })
+      const mockAuthCache = createMockAuthCache({
+        get: () => blockedResult,
+      })
+      strategy = new LocalAuthStrategy(undefined, mockAuthCache)
+      const config = createTestAuthConfig({
+        authorizationCacheEnabled: true,
+        disablePostAuthorize: false,
+      })
+      strategy.initialize(config)
+      const request = createMockAuthRequest({
+        identifier: createMockIdentifier(
+          OCPPVersion.VERSION_20,
+          'BLOCKED-TAG',
+          IdentifierType.ISO14443
+        ),
+      })
+
+      // Act
+      const result = await strategy.authenticate(request, config)
+
+      // Assert - undefined signals orchestrator should try remote strategy
+      assert.strictEqual(result, undefined)
+    })
+  })
+
+  await describe('C14.FR.03 - local list post-authorize', async () => {
+    await it('should accept non-Accepted local list token without re-auth when DisablePostAuthorize=true', async () => {
+      const localListManager = createMockLocalAuthListManager({
+        getEntry: () =>
+          new Promise(resolve => {
+            resolve({
+              identifier: 'BLOCKED-TAG',
+              status: 'Blocked',
+            })
+          }),
+      })
+      strategy = new LocalAuthStrategy(localListManager, undefined)
+      const config = createTestAuthConfig({
+        disablePostAuthorize: true,
+        localAuthListEnabled: true,
+      })
+      strategy.initialize(config)
+      const request = createMockAuthRequest({
+        identifier: createMockIdentifier(
+          OCPPVersion.VERSION_20,
+          'BLOCKED-TAG',
+          IdentifierType.ISO14443
+        ),
+      })
+
+      const result = await strategy.authenticate(request, config)
+
+      assert.notStrictEqual(result, undefined)
+      assert.strictEqual(result?.status, AuthorizationStatus.BLOCKED)
+      assert.strictEqual(result.method, AuthenticationMethod.LOCAL_LIST)
+    })
+  })
+
+  await describe('default behavior', async () => {
+    await it('should be no-op when DisablePostAuthorize not configured (default behavior preserved)', async () => {
+      // Arrange
+      const blockedResult = createMockAuthorizationResult({
+        method: AuthenticationMethod.CACHE,
+        status: AuthorizationStatus.BLOCKED,
+      })
+      const mockAuthCache = createMockAuthCache({
+        get: () => blockedResult,
+      })
+      strategy = new LocalAuthStrategy(undefined, mockAuthCache)
+      const config = createTestAuthConfig({
+        authorizationCacheEnabled: true,
+      })
+      strategy.initialize(config)
+      const request = createMockAuthRequest({
+        identifier: createMockIdentifier(
+          OCPPVersion.VERSION_20,
+          'BLOCKED-TAG',
+          IdentifierType.ISO14443
+        ),
+      })
+
+      // Act
+      const result = await strategy.authenticate(request, config)
+
+      // Assert - returns cached result as-is (disablePostAuthorize not in config)
+      assert.notStrictEqual(result, undefined)
+      assert.strictEqual(result?.status, AuthorizationStatus.BLOCKED)
+    })
+  })
+})