From 70d89a2226f705ee5610f2f70b609d71c6142026 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Mon, 23 Feb 2026 23:48:03 +0100 Subject: [PATCH] fix(ocpp2): implement ClearCache and Reset OnIdle fixes per audit (CLR-001, CLR-002, RST-001) - 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) --- .../ocpp/2.0/OCPP20IncomingRequestService.ts | 110 ++++++++++- .../ocpp/OCPPIncomingRequestService.ts | 4 +- ...0IncomingRequestService-ClearCache.test.ts | 182 +++++++++++++++++- 3 files changed, 281 insertions(+), 15 deletions(-) diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index c59c394e..9a7b51ba 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -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.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 { + 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` diff --git a/src/charging-station/ocpp/OCPPIncomingRequestService.ts b/src/charging-station/ocpp/OCPPIncomingRequestService.ts index 5b6819c0..5a1b8d9e 100644 --- a/src/charging-station/ocpp/OCPPIncomingRequestService.ts +++ b/src/charging-station/ocpp/OCPPIncomingRequestService.ts @@ -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 { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (chargingStation.idTagsCache.deleteIdTags(getIdTagsFile(chargingStation.stationInfo!)!)) { return OCPPConstants.OCPP_RESPONSE_ACCEPTED diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts index 1c100c48..32c8d705 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ClearCache.test.ts @@ -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 => { + 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 => + 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 => { + 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 => + 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 => { + // 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 => + 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 => { + 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 => + 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 => { + 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 => + 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 + } + }) }) }) -- 2.53.0