]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp2): implement ClearCache and Reset OnIdle fixes per audit (CLR-001, CLR-002...
authorJérôme Benoit <jerome.benoit@sap.com>
Mon, 23 Feb 2026 22:48:03 +0000 (23:48 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Tue, 24 Feb 2026 14:29:56 +0000 (15:29 +0100)
- Override handleRequestClearCache() for OCPP 2.0.1 to use Authorization Cache
- Add AuthCacheEnabled check per C11.FR.04 (return Rejected if disabled)
- Extend Reset OnIdle to check firmware updates and reservations per errata 2.14
- Add comprehensive test coverage for ClearCache spec compliance
- Update base class signature to support async ClearCache in OCPP 2.0.1
- Fix ESLint errors in test file (remove unnecessary conditionals, bind methods)

src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/OCPPIncomingRequestService.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts

index c59c394e7e9f19aa8e50bec58e1624729de5afc3..9a7b51baea7151cb2ccbebf132dea320bba6c71f 100644 (file)
@@ -16,11 +16,13 @@ import {
   ConnectorStatusEnum,
   DataEnumType,
   ErrorType,
+  FirmwareStatus,
   GenericDeviceModelStatusEnumType,
   GenericStatus,
   GetVariableStatusEnumType,
   type IncomingRequestHandler,
   type JsonType,
+  type OCPP20ClearCacheResponse,
   OCPP20ComponentName,
   OCPP20ConnectorStatusEnumType,
   OCPP20DeviceInfoVariableName,
@@ -68,13 +70,15 @@ import {
   validateIdentifierString,
 } from '../../../utils/index.js'
 import { getConfigurationKey } from '../../ConfigurationKeyUtils.js'
-import { resetConnectorStatus } from '../../Helpers.js'
+import { hasReservationExpired, resetConnectorStatus } from '../../Helpers.js'
+import { OCPPAuthServiceFactory } from '../auth/services/OCPPAuthServiceFactory.js'
 import { OCPPIncomingRequestService } from '../OCPPIncomingRequestService.js'
 import {
   OCPPServiceUtils,
   restoreConnectorStatus,
   sendAndSetConnectorStatus,
 } from '../OCPPServiceUtils.js'
+import { OCPP20Constants } from './OCPP20Constants.js'
 import { OCPP20ServiceUtils } from './OCPP20ServiceUtils.js'
 import { OCPP20VariableManager } from './OCPP20VariableManager.js'
 import { getVariableMetadata, VARIABLE_REGISTRY } from './OCPP20VariableRegistry.js'
@@ -141,7 +145,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     this.incomingRequestHandlers = new Map<OCPP20IncomingRequestCommand, IncomingRequestHandler>([
       [
         OCPP20IncomingRequestCommand.CLEAR_CACHE,
-        super.handleRequestClearCache.bind(this) as IncomingRequestHandler,
+        this.handleRequestClearCache.bind(this) as unknown as IncomingRequestHandler,
       ],
       [
         OCPP20IncomingRequestCommand.GET_BASE_REPORT,
@@ -477,6 +481,40 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     }
   }
 
+  /**
+   * Handles OCPP 2.0.1 ClearCache request by clearing the Authorization Cache
+   * per OCPP 2.0.1 spec C11.FR.01
+   * Per C11.FR.04: Returns Rejected if AuthCacheEnabled is false
+   * @param chargingStation - The charging station instance
+   * @returns Promise resolving to ClearCacheResponse
+   */
+  protected override async handleRequestClearCache (
+    chargingStation: ChargingStation
+  ): Promise<OCPP20ClearCacheResponse> {
+    try {
+      const authService = await OCPPAuthServiceFactory.getInstance(chargingStation)
+      // C11.FR.04: IF AuthCacheEnabled is false, CS SHALL send ClearCacheResponse with status Rejected
+      const config = authService.getConfiguration()
+      if (!config.authorizationCacheEnabled) {
+        logger.info(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestClearCache: Authorization cache disabled, returning Rejected (C11.FR.04)`
+        )
+        return OCPP20Constants.OCPP_RESPONSE_REJECTED
+      }
+      await authService.clearCache()
+      logger.info(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestClearCache: Authorization cache cleared`
+      )
+      return OCPP20Constants.OCPP_RESPONSE_ACCEPTED
+    } catch (error) {
+      logger.error(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestClearCache: Error clearing cache:`,
+        error
+      )
+      return OCPP20Constants.OCPP_RESPONSE_REJECTED
+    }
+  }
+
   private buildReportData (
     chargingStation: ChargingStation,
     reportBase: ReportBaseEnumType
@@ -1515,19 +1553,40 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
    * @param evseId - The EVSE identifier to reset
    */
   private scheduleEvseResetOnIdle (chargingStation: ChargingStation, evseId: number): void {
-    // Monitor for transaction completion and reset when idle
+    // Monitor for idle state per Errata 2.14 and reset when idle
     const monitorInterval = setInterval(() => {
       const evse = chargingStation.evses.get(evseId)
       if (evse) {
+        // Check all idle conditions per Errata 2.14 (OnIdle definition):
+        // 1. Active transactions on EVSE
         let hasActiveTransactions = false
+        let hasPendingReservation = false
         for (const [, connector] of evse.connectors) {
+          // Check for active transaction
           if (connector.transactionId !== undefined) {
             hasActiveTransactions = true
-            break
+          }
+          // Check for pending (non-expired) reservation
+          if (connector.reservation != null && !hasReservationExpired(connector.reservation)) {
+            hasPendingReservation = true
           }
         }
 
-        if (!hasActiveTransactions) {
+        // 2. Firmware update in progress (station-wide affects all EVSEs)
+        const firmwareStatus = chargingStation.stationInfo?.firmwareStatus
+        const hasFirmwareUpdateInProgress =
+          firmwareStatus === FirmwareStatus.Downloading ||
+          firmwareStatus === FirmwareStatus.Downloaded ||
+          firmwareStatus === FirmwareStatus.Installing
+
+        // Note: Log uploads and cable lock state are not tracked in the simulator.
+        // Per Errata 2.14, these would also prevent idle state, but the simulator
+        // does not implement log upload tracking or separate cable lock state.
+
+        const isIdle =
+          !hasActiveTransactions && !hasFirmwareUpdateInProgress && !hasPendingReservation
+
+        if (isIdle) {
           clearInterval(monitorInterval)
           logger.info(
             `${chargingStation.logPrefix()} ${moduleName}.scheduleEvseResetOnIdle: EVSE ${evseId.toString()} is now idle, executing reset`
@@ -1545,11 +1604,48 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
    * @param chargingStation - The charging station instance
    */
   private scheduleResetOnIdle (chargingStation: ChargingStation): void {
-    // Monitor for transaction completion and reset when idle
+    // Monitor for idle state per Errata 2.14 and reset when idle
     const monitorInterval = setInterval(() => {
+      // Check all idle conditions per Errata 2.14 (OnIdle definition):
+      // 1. Active transactions
       const hasActiveTransactions = chargingStation.getNumberOfRunningTransactions() > 0
 
-      if (!hasActiveTransactions) {
+      // 2. Firmware update in progress
+      const firmwareStatus = chargingStation.stationInfo?.firmwareStatus
+      const hasFirmwareUpdateInProgress =
+        firmwareStatus === FirmwareStatus.Downloading ||
+        firmwareStatus === FirmwareStatus.Downloaded ||
+        firmwareStatus === FirmwareStatus.Installing
+
+      // 3. Pending reservations (non-expired)
+      let hasPendingReservation = false
+      if (chargingStation.hasEvses) {
+        for (const evse of chargingStation.evses.values()) {
+          for (const connector of evse.connectors.values()) {
+            if (connector.reservation != null && !hasReservationExpired(connector.reservation)) {
+              hasPendingReservation = true
+              break
+            }
+          }
+          if (hasPendingReservation) break
+        }
+      } else {
+        for (const connector of chargingStation.connectors.values()) {
+          if (connector.reservation != null && !hasReservationExpired(connector.reservation)) {
+            hasPendingReservation = true
+            break
+          }
+        }
+      }
+
+      // Note: Log uploads and cable lock state are not tracked in the simulator.
+      // Per Errata 2.14, these would also prevent idle state, but the simulator
+      // does not implement log upload tracking or separate cable lock state.
+
+      const isIdle =
+        !hasActiveTransactions && !hasFirmwareUpdateInProgress && !hasPendingReservation
+
+      if (isIdle) {
         clearInterval(monitorInterval)
         logger.info(
           `${chargingStation.logPrefix()} ${moduleName}.scheduleResetOnIdle: Charging station is now idle, executing reset`
index 5b6819c02c14d094a1991d64fd43fea2a83d9a5a..5a1b8d9ebeab00578aaab7654eb3e159e78a53e9 100644 (file)
@@ -59,7 +59,9 @@ export abstract class OCPPIncomingRequestService extends EventEmitter {
 
   public abstract stop (chargingStation: ChargingStation): void
 
-  protected handleRequestClearCache (chargingStation: ChargingStation): ClearCacheResponse {
+  protected handleRequestClearCache (
+    chargingStation: ChargingStation
+  ): ClearCacheResponse | Promise<ClearCacheResponse> {
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     if (chargingStation.idTagsCache.deleteIdTags(getIdTagsFile(chargingStation.stationInfo!)!)) {
       return OCPPConstants.OCPP_RESPONSE_ACCEPTED
index 1c100c48b62591aaa7a435574844b100bdd81e09..32c8d70561a600a054720696f10c44340c6ca397 100644 (file)
@@ -7,7 +7,8 @@ import { expect } from '@std/expect'
 import { describe, it } from 'node:test'
 
 import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
-import { OCPPVersion } from '../../../../src/types/index.js'
+import { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/auth/services/OCPPAuthServiceFactory.js'
+import { GenericStatus, OCPPVersion } from '../../../../src/types/index.js'
 import { Constants } from '../../../../src/utils/index.js'
 import { createChargingStation } from '../../../ChargingStationFactory.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from './OCPP20TestConstants.js'
@@ -27,7 +28,7 @@ await describe('C11 - Clear Authorization Data in Authorization Cache', async ()
 
   const incomingRequestService = new OCPP20IncomingRequestService()
 
-  // FR: C11.FR.01
+  // FR: C11.FR.01 - CS SHALL attempt to clear its Authorization Cache
   await it('Should handle ClearCache request successfully', async () => {
     const response = await (incomingRequestService as any).handleRequestClearCache(
       mockChargingStation
@@ -37,13 +38,11 @@ await describe('C11 - Clear Authorization Data in Authorization Cache', async ()
     expect(typeof response).toBe('object')
     expect(response.status).toBeDefined()
     expect(typeof response.status).toBe('string')
-    expect(['Accepted', 'Rejected']).toContain(response.status)
+    expect([GenericStatus.Accepted, GenericStatus.Rejected]).toContain(response.status)
   })
 
-  // FR: C11.FR.02
+  // FR: C11.FR.02 - Return correct status based on cache clearing result
   await it('Should return correct status based on cache clearing result', async () => {
-    // Test the actual behavior - ClearCache should work with ID tags cache
-
     const response = await (incomingRequestService as any).handleRequestClearCache(
       mockChargingStation
     )
@@ -51,6 +50,175 @@ await describe('C11 - Clear Authorization Data in Authorization Cache', async ()
     expect(response).toBeDefined()
     expect(response.status).toBeDefined()
     // Should be either Accepted or Rejected based on cache state
-    expect(['Accepted', 'Rejected']).toContain(response.status)
+    expect([GenericStatus.Accepted, GenericStatus.Rejected]).toContain(response.status)
+  })
+
+  // CLR-001: Verify Authorization Cache is cleared (not IdTagsCache)
+  await describe('CLR-001 - ClearCache clears Authorization Cache', async () => {
+    await it('Should call authService.clearCache() on ClearCache request', async () => {
+      // Create a mock auth service to verify clearCache is called
+      let clearCacheCalled = false
+      const mockAuthService = {
+        clearCache: (): Promise<void> => {
+          clearCacheCalled = true
+          return Promise.resolve()
+        },
+        getConfiguration: () => ({
+          authorizationCacheEnabled: true,
+        }),
+      }
+
+      // Mock the factory to return our mock auth service
+      const originalGetInstance = OCPPAuthServiceFactory.getInstance.bind(OCPPAuthServiceFactory)
+      ;(OCPPAuthServiceFactory as any).getInstance = (): Promise<typeof mockAuthService> =>
+        Promise.resolve(mockAuthService)
+
+      try {
+        const response = await (incomingRequestService as any).handleRequestClearCache(
+          mockChargingStation
+        )
+
+        expect(clearCacheCalled).toBe(true)
+        expect(response.status).toBe(GenericStatus.Accepted)
+      } finally {
+        // Restore original factory method
+        ;(OCPPAuthServiceFactory as any).getInstance = originalGetInstance
+      }
+    })
+
+    await it('Should NOT call idTagsCache.deleteIdTags() on ClearCache request', async () => {
+      // Verify that IdTagsCache is not touched
+      let deleteIdTagsCalled = false
+      // eslint-disable-next-line @typescript-eslint/unbound-method
+      const originalDeleteIdTags = mockChargingStation.idTagsCache.deleteIdTags
+
+      ;(mockChargingStation.idTagsCache as any).deleteIdTags = () => {
+        deleteIdTagsCalled = true
+      }
+
+      try {
+        await (incomingRequestService as any).handleRequestClearCache(mockChargingStation)
+        expect(deleteIdTagsCalled).toBe(false)
+      } finally {
+        // Restore original method
+        ;(mockChargingStation.idTagsCache as any).deleteIdTags = originalDeleteIdTags
+      }
+    })
+  })
+
+  // CLR-002: Verify AuthCacheEnabled check per C11.FR.04
+  await describe('CLR-002 - AuthCacheEnabled Check (C11.FR.04)', async () => {
+    await it('Should return Rejected when AuthCacheEnabled is false', async () => {
+      // Create a mock auth service with cache disabled
+      const mockAuthService = {
+        clearCache: (): Promise<void> => {
+          throw new Error('clearCache should not be called when cache is disabled')
+        },
+        getConfiguration: () => ({
+          authorizationCacheEnabled: false,
+        }),
+      }
+
+      // Mock the factory to return our mock auth service
+      const originalGetInstance = OCPPAuthServiceFactory.getInstance.bind(OCPPAuthServiceFactory)
+      ;(OCPPAuthServiceFactory as any).getInstance = (): Promise<typeof mockAuthService> =>
+        Promise.resolve(mockAuthService)
+
+      try {
+        const response = await (incomingRequestService as any).handleRequestClearCache(
+          mockChargingStation
+        )
+
+        expect(response.status).toBe(GenericStatus.Rejected)
+      } finally {
+        // Restore original factory method
+        ;(OCPPAuthServiceFactory as any).getInstance = originalGetInstance
+      }
+    })
+
+    await it('Should return Accepted when AuthCacheEnabled is true and clear succeeds', async () => {
+      // Create a mock auth service with cache enabled
+      const mockAuthService = {
+        clearCache: (): Promise<void> => {
+          // Successful clear
+          return Promise.resolve()
+        },
+        getConfiguration: () => ({
+          authorizationCacheEnabled: true,
+        }),
+      }
+
+      // Mock the factory to return our mock auth service
+      const originalGetInstance = OCPPAuthServiceFactory.getInstance.bind(OCPPAuthServiceFactory)
+      ;(OCPPAuthServiceFactory as any).getInstance = (): Promise<typeof mockAuthService> =>
+        Promise.resolve(mockAuthService)
+
+      try {
+        const response = await (incomingRequestService as any).handleRequestClearCache(
+          mockChargingStation
+        )
+
+        expect(response.status).toBe(GenericStatus.Accepted)
+      } finally {
+        // Restore original factory method
+        ;(OCPPAuthServiceFactory as any).getInstance = originalGetInstance
+      }
+    })
+
+    await it('Should return Rejected when clearCache throws an error', async () => {
+      // Create a mock auth service that throws on clearCache
+      const mockAuthService = {
+        clearCache: (): Promise<void> => {
+          return Promise.reject(new Error('Cache clear failed'))
+        },
+        getConfiguration: () => ({
+          authorizationCacheEnabled: true,
+        }),
+      }
+
+      // Mock the factory to return our mock auth service
+      const originalGetInstance = OCPPAuthServiceFactory.getInstance.bind(OCPPAuthServiceFactory)
+      ;(OCPPAuthServiceFactory as any).getInstance = (): Promise<typeof mockAuthService> =>
+        Promise.resolve(mockAuthService)
+
+      try {
+        const response = await (incomingRequestService as any).handleRequestClearCache(
+          mockChargingStation
+        )
+
+        expect(response.status).toBe(GenericStatus.Rejected)
+      } finally {
+        // Restore original factory method
+        ;(OCPPAuthServiceFactory as any).getInstance = originalGetInstance
+      }
+    })
+
+    await it('Should not attempt to clear cache when AuthCacheEnabled is false', async () => {
+      let clearCacheAttempted = false
+      const mockAuthService = {
+        clearCache: (): Promise<void> => {
+          clearCacheAttempted = true
+          return Promise.resolve()
+        },
+        getConfiguration: () => ({
+          authorizationCacheEnabled: false,
+        }),
+      }
+
+      // Mock the factory to return our mock auth service
+      const originalGetInstance = OCPPAuthServiceFactory.getInstance.bind(OCPPAuthServiceFactory)
+      ;(OCPPAuthServiceFactory as any).getInstance = (): Promise<typeof mockAuthService> =>
+        Promise.resolve(mockAuthService)
+
+      try {
+        await (incomingRequestService as any).handleRequestClearCache(mockChargingStation)
+
+        // clearCache should NOT be called when cache is disabled
+        expect(clearCacheAttempted).toBe(false)
+      } finally {
+        // Restore original factory method
+        ;(OCPPAuthServiceFactory as any).getInstance = originalGetInstance
+      }
+    })
   })
 })