From 9d56abe76cf8f9459529e69b1e497a93b8b54031 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 26 Mar 2026 20:06:53 +0100 Subject: [PATCH] feat(ocpp2): add configurable firmware signature verification simulation (L01.FR.03/L01.FR.04) (#1757) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * feat(ocpp2): add FirmwareCtrlr component and signature verification variable * test(ocpp2): add firmware signature verification failure tests - Add test for InvalidSignature path when SimulateSignatureVerificationFailure is true - Add test for normal path when SimulateSignatureVerificationFailure is false (TDD red phase) - Tests verify lifecycle termination and SecurityEventNotification type correctness - Evidence: typecheck & lint pass; tests fail as expected (awaiting Task 3 implementation) * feat(ocpp2): implement configurable firmware signature verification simulation * docs: update README firmware signature verification note * fix(ocpp2): address PR review feedback on firmware signature verification - Await sendSecurityEventNotification in failure path for consistency with success path - Reorder test assertions: check array length before index access - Add tick-forward verification that lifecycle stops after InvalidSignature - Remove redundant standardCleanup() calls already handled by afterEach - Remove firmware signature verification note from README * fix(ocpp2): improve firmware update spec conformance and test quality - L01.FR.07: Move EVSE unavailability check inside wait loop for continuous monitoring - L01.FR.30: Honor retries/retryInterval with simulated download retry cycle - Merge retry test into existing DownloadFailed test (DRY) - Add EVSE continuous monitoring lifecycle test (L01.FR.07) - Fix assertion ordering: length checks before index access - Remove as-unknown-as-string cast in favor of String() - Tighten assert.ok to assert.notStrictEqual where applicable * fix(tests): type CapturedOCPPRequest.command as OCPP20RequestCommand - Replace string type with OCPP20RequestCommand enum on CapturedOCPPRequest.command - Remove 6 unnecessary 'as string' casts across UpdateFirmware and TransactionEvent tests - Replace 'Unavailable' string literal with OCPP20ConnectorStatusEnumType.Unavailable - Remove unnecessary optional chaining on non-nullable payload - Complete @param JSDoc for retries/retryInterval on simulateFirmwareUpdateLifecycle * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes (attempt 2/3) * fix(tests): replace flaky sentRequests assertions with mock.method spy in EVSE monitoring test Mock sendEvseStatusNotifications directly instead of asserting on fire-and-forget side effects captured in sentRequests. The internal async chain (sendAndSetConnectorStatus → dynamic import → requestHandler) needed non-deterministic flushMicrotasks() stacking. The spy makes assertions synchronous and CI-deterministic. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- README.md | 2 - .../ocpp/2.0/OCPP20IncomingRequestService.ts | 88 +++++- .../ocpp/2.0/OCPP20VariableRegistry.ts | 17 + src/types/ocpp/2.0/Common.ts | 1 + ...ngRequestService-CertificateSigned.test.ts | 3 +- ...omingRequestService-UpdateFirmware.test.ts | 296 +++++++++++++++++- ...CPP20ServiceUtils-TransactionEvent.test.ts | 10 +- .../ocpp/2.0/OCPP20TestUtils.ts | 6 +- 8 files changed, 392 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 6cd19047..8a70d1eb 100644 --- a/README.md +++ b/README.md @@ -595,8 +595,6 @@ make SUBMODULES_INIT=true - :white_check_mark: FirmwareStatusNotification - :white_check_mark: UpdateFirmware -> **Note**: Firmware signature verification required by L01.FR.04 always succeeds (`SignatureVerified`). `retries`/`retryInterval` parameters are accepted but not honored. - #### M. ISO 15118 CertificateManagement - :white_check_mark: CertificateSigned diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index 61f42c40..417f6aa8 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -336,7 +336,9 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { this.simulateFirmwareUpdateLifecycle( chargingStation, request.requestId, - request.firmware + request.firmware, + request.retries, + request.retryInterval ).catch((error: unknown) => { logger.error( `${chargingStation.logPrefix()} ${moduleName}.constructor: UpdateFirmware lifecycle error:`, @@ -3394,11 +3396,15 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { * @param chargingStation - The charging station instance * @param requestId - The request ID from the UpdateFirmware request * @param firmware - The firmware details including location, dates, and optional signature + * @param retries - Number of download retry attempts before reporting DownloadFailed (L01.FR.30) + * @param retryInterval - Seconds between download retry attempts */ private async simulateFirmwareUpdateLifecycle ( chargingStation: ChargingStation, requestId: number, - firmware: FirmwareType + firmware: FirmwareType, + retries?: number, + retryInterval?: number ): Promise { const { installDateTime, location, retrieveDateTime, signature } = firmware @@ -3434,13 +3440,30 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { // H9: If firmware location is empty or malformed, send DownloadFailed and stop if (location.trim() === '' || !this.isValidFirmwareLocation(location)) { + // L01.FR.30: Simulate download retries before reporting DownloadFailed + const maxRetries = retries ?? 0 + const retryDelayMs = (retryInterval ?? 0) * 1000 + for (let attempt = 1; attempt <= maxRetries; attempt++) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.simulateFirmwareUpdateLifecycle: Download failed for requestId ${requestId.toString()} - invalid location '${location}' (attempt ${attempt.toString()}/${maxRetries.toString()}, retrying in ${retryInterval?.toString() ?? '0'}s)` + ) + await sleep(retryDelayMs) + if (checkAborted()) return + await this.sendFirmwareStatusNotification( + chargingStation, + OCPP20FirmwareStatusEnumType.Downloading, + requestId + ) + await sleep(OCPP20Constants.FIRMWARE_STATUS_DELAY_MS) + if (checkAborted()) return + } await this.sendFirmwareStatusNotification( chargingStation, OCPP20FirmwareStatusEnumType.DownloadFailed, requestId ) logger.warn( - `${chargingStation.logPrefix()} ${moduleName}.simulateFirmwareUpdateLifecycle: Download failed for requestId ${requestId.toString()} - invalid location '${location}'` + `${chargingStation.logPrefix()} ${moduleName}.simulateFirmwareUpdateLifecycle: Download failed for requestId ${requestId.toString()} - invalid location '${location}'${maxRetries > 0 ? ` (exhausted ${maxRetries.toString()} retries)` : ''}` ) this.clearActiveFirmwareUpdate(chargingStation, requestId) return @@ -3455,6 +3478,42 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { if (signature != null) { await sleep(OCPP20Constants.FIRMWARE_VERIFY_DELAY_MS) if (checkAborted()) return + + // L01.FR.04: Simulate signature verification + const variableManager = OCPP20VariableManager.getInstance() + const verificationResults = variableManager.getVariables(chargingStation, [ + { + attributeType: AttributeEnumType.Actual, + component: { name: OCPP20ComponentName.FirmwareCtrlr as string }, + variable: { name: 'SimulateSignatureVerificationFailure' }, + }, + ]) + const simulateFailure = verificationResults[0]?.attributeValue?.toLowerCase() === 'true' + + if (simulateFailure) { + // L01.FR.03: InvalidSignature + SecurityEventNotification + await this.sendFirmwareStatusNotification( + chargingStation, + OCPP20FirmwareStatusEnumType.InvalidSignature, + requestId + ) + await this.sendSecurityEventNotification( + chargingStation, + 'InvalidFirmwareSignature', + `Firmware signature verification failed for requestId ${requestId.toString()}` + ).catch((error: unknown) => { + logger.error( + `${chargingStation.logPrefix()} ${moduleName}.simulateFirmwareUpdateLifecycle: SecurityEventNotification error:`, + error + ) + }) + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.simulateFirmwareUpdateLifecycle: Firmware signature verification failed for requestId ${requestId.toString()} (simulated)` + ) + this.clearActiveFirmwareUpdate(chargingStation, requestId) + return + } + await this.sendFirmwareStatusNotification( chargingStation, OCPP20FirmwareStatusEnumType.SignatureVerified, @@ -3492,23 +3551,24 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { }, ]) const allowNewSessions = allowNewSessionsResults[0]?.attributeValue?.toLowerCase() === 'true' - if (!allowNewSessions) { - for (const [fwEvseId, fwEvseStatus] of chargingStation.evses) { - if (fwEvseId > 0 && !this.hasEvseActiveTransactions(fwEvseStatus)) { - this.sendEvseStatusNotifications( - chargingStation, - fwEvseId, - OCPP20ConnectorStatusEnumType.Unavailable - ) - } - } - } while ( !checkAborted() && [...chargingStation.evses].some( ([evseId, evse]) => evseId > 0 && this.hasEvseActiveTransactions(evse) ) ) { + // L01.FR.07: Set newly-available EVSE to Unavailable on each iteration + if (!allowNewSessions) { + for (const [fwEvseId, fwEvseStatus] of chargingStation.evses) { + if (fwEvseId > 0 && !this.hasEvseActiveTransactions(fwEvseStatus)) { + this.sendEvseStatusNotifications( + chargingStation, + fwEvseId, + OCPP20ConnectorStatusEnumType.Unavailable + ) + } + } + } logger.debug( `${chargingStation.logPrefix()} ${moduleName}.simulateFirmwareUpdateLifecycle: Waiting for active transactions to end before installing (L01.FR.06)` ) diff --git a/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts b/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts index da809ee3..469944c6 100644 --- a/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts +++ b/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts @@ -984,6 +984,23 @@ export const VARIABLE_REGISTRY: Record = { variable: 'SupplyPhases', }, + // FirmwareCtrlr Component + [buildRegistryKey( + OCPP20ComponentName.FirmwareCtrlr as string, + 'SimulateSignatureVerificationFailure' + )]: { + component: OCPP20ComponentName.FirmwareCtrlr as string, + dataType: DataEnumType.boolean, + defaultValue: 'false', + description: + 'When true, firmware signature verification is simulated as failed (L01.FR.03/L01.FR.04).', + mutability: MutabilityEnumType.ReadWrite, + persistence: PersistenceEnumType.Persistent, + supportedAttributes: [AttributeEnumType.Actual], + variable: 'SimulateSignatureVerificationFailure', + vendorSpecific: true, + }, + // ISO15118Ctrlr Component [buildRegistryKey( OCPP20ComponentName.ISO15118Ctrlr as string, diff --git a/src/types/ocpp/2.0/Common.ts b/src/types/ocpp/2.0/Common.ts index 21467ce8..8d13fe57 100644 --- a/src/types/ocpp/2.0/Common.ts +++ b/src/types/ocpp/2.0/Common.ts @@ -181,6 +181,7 @@ export enum OCPP20ComponentName { EVRetentionLock = 'EVRetentionLock', EVSE = 'EVSE', ExternalTemperatureSensor = 'ExternalTemperatureSensor', + FirmwareCtrlr = 'FirmwareCtrlr', FiscalMetering = 'FiscalMetering', FloodSensor = 'FloodSensor', GroundIsolationProtection = 'GroundIsolationProtection', diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts index 5dff150a..9ce65d53 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts @@ -370,8 +370,7 @@ await describe('I04 - CertificateSigned', async () => { assert.strictEqual(response.status, GenericStatus.Rejected) const securityEvents = sentRequests.filter( - r => - (r.command as OCPP20RequestCommand) === OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION + r => r.command === OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION ) assert.strictEqual(securityEvents.length, 1) assert.strictEqual(securityEvents[0].payload.type, 'InvalidChargingStationCertificate') diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts index 3d92d451..2f8d1357 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts @@ -12,6 +12,7 @@ import { createTestableIncomingRequestService } from '../../../../src/charging-s import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js' import { type FirmwareType, + OCPP20ConnectorStatusEnumType, OCPP20FirmwareStatusEnumType, OCPP20IncomingRequestCommand, OCPP20RequestCommand, @@ -461,7 +462,7 @@ await describe('L01/L02 - UpdateFirmware', async () => { }) }) - await it('should send DownloadFailed for malformed firmware location', async t => { + await it('should send DownloadFailed for malformed firmware location after exhausting retries (L01.FR.30)', async t => { const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() const service = new OCPP20IncomingRequestService() @@ -471,6 +472,8 @@ await describe('L01/L02 - UpdateFirmware', async () => { retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), }, requestId: 8, + retries: 2, + retryInterval: 3, } const response: OCPP20UpdateFirmwareResponse = { status: UpdateFirmwareStatusEnumType.Accepted, @@ -485,15 +488,147 @@ await describe('L01/L02 - UpdateFirmware', async () => { ) await flushMicrotasks() + assert.strictEqual(sentRequests.length, 1) + assert.strictEqual( + sentRequests[0].payload.status, + OCPP20FirmwareStatusEnumType.Downloading + ) + + // Initial download delay t.mock.timers.tick(2000) await flushMicrotasks() + // Retry 1: retryInterval (3s) then re-send Downloading + t.mock.timers.tick(3000) + await flushMicrotasks() assert.strictEqual(sentRequests.length, 2) assert.strictEqual( sentRequests[1].payload.status, + OCPP20FirmwareStatusEnumType.Downloading + ) + + // Retry 1: download delay (2s) + t.mock.timers.tick(2000) + await flushMicrotasks() + + // Retry 2: retryInterval (3s) then re-send Downloading + t.mock.timers.tick(3000) + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 3) + assert.strictEqual( + sentRequests[2].payload.status, + OCPP20FirmwareStatusEnumType.Downloading + ) + + // Retry 2: download delay (2s) → retries exhausted → DownloadFailed + t.mock.timers.tick(2000) + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 4) + assert.strictEqual( + sentRequests[3].payload.status, OCPP20FirmwareStatusEnumType.DownloadFailed ) - assert.strictEqual(sentRequests[1].payload.requestId, 8) + assert.strictEqual(sentRequests[3].payload.requestId, 8) + }) + }) + + await it('should set newly-available EVSE to Unavailable during transaction wait (L01.FR.07)', async t => { + const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + const service = new OCPP20IncomingRequestService() + const sendEvseStatusMock = mock.method( + service as unknown as { + sendEvseStatusNotifications: ( + chargingStation: ChargingStation, + evseId: number, + status: OCPP20ConnectorStatusEnumType + ) => void + }, + 'sendEvseStatusNotifications', + () => undefined + ) + const { OCPP20VariableManager } = + await import('../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js') + const { AttributeEnumType, OCPP20ComponentName } = + await import('../../../../src/types/index.js') + + OCPP20VariableManager.getInstance().setVariables(trackingStation, [ + { + attributeType: AttributeEnumType.Actual, + attributeValue: 'false', + component: { name: OCPP20ComponentName.ChargingStation as string }, + variable: { name: 'AllowNewSessionsPendingFirmwareUpdate' }, + }, + ]) + + // Set active transactions on EVSE 1 and EVSE 2 + const evse1 = trackingStation.getEvseStatus(1) + const evse2 = trackingStation.getEvseStatus(2) + const evse1Connector = evse1?.connectors.values().next().value + const evse2Connector = evse2?.connectors.values().next().value + if (evse1Connector != null) evse1Connector.transactionId = 'tx-fw-001' + if (evse2Connector != null) evse2Connector.transactionId = 'tx-fw-002' + + const request: OCPP20UpdateFirmwareRequest = { + firmware: { + location: 'https://firmware.example.com/update.bin', + retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), + }, + requestId: 10, + } + const response: OCPP20UpdateFirmwareResponse = { + status: UpdateFirmwareStatusEnumType.Accepted, + } + + await withMockTimers(t, ['setTimeout'], async () => { + service.emit( + OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, + trackingStation, + request, + response + ) + + // Downloading + await flushMicrotasks() + // Downloaded → enters transaction-wait loop + t.mock.timers.tick(2000) + await flushMicrotasks() + + // EVSE 3 (no transaction) should be set Unavailable + assert.strictEqual(sendEvseStatusMock.mock.callCount(), 1) + assert.strictEqual(sendEvseStatusMock.mock.calls[0].arguments[1], 3) + assert.strictEqual( + sendEvseStatusMock.mock.calls[0].arguments[2], + OCPP20ConnectorStatusEnumType.Unavailable + ) + + // Clear EVSE 2's transaction → it becomes available + if (evse2Connector != null) evse2Connector.transactionId = undefined + + // Advance one loop iteration (FIRMWARE_INSTALL_DELAY_MS = 5000ms) + t.mock.timers.tick(5000) + await flushMicrotasks() + + // EVSE 2 and EVSE 3 should now also be set to Unavailable + assert.strictEqual(sendEvseStatusMock.mock.callCount(), 3) + const secondIterationEvseIds = sendEvseStatusMock.mock.calls + .slice(1) + .map(call => Number(call.arguments[1])) + assert.ok( + secondIterationEvseIds.includes(2), + 'Expected EVSE 2 to be set Unavailable after clearing its transaction' + ) + assert.ok(secondIterationEvseIds.includes(3), 'Expected EVSE 3 to remain Unavailable') + + // Clear EVSE 1's transaction to let the lifecycle proceed + if (evse1Connector != null) evse1Connector.transactionId = undefined + t.mock.timers.tick(5000) + await flushMicrotasks() + + // Lifecycle should proceed to Installing + const installingRequests = sentRequests.filter( + req => req.payload.status === OCPP20FirmwareStatusEnumType.Installing + ) + assert.strictEqual(installingRequests.length, 1) }) }) @@ -528,9 +663,7 @@ await describe('L01/L02 - UpdateFirmware', async () => { await flushMicrotasks() const firmwareNotifications = sentRequests.filter( - r => - (r.command as OCPP20RequestCommand) === - OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION + r => r.command === OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION ) assert.strictEqual(firmwareNotifications.length, 4) for (const req of firmwareNotifications) { @@ -576,6 +709,7 @@ await describe('L01/L02 - UpdateFirmware', async () => { t.mock.timers.tick(500) await flushMicrotasks() + assert.strictEqual(sentRequests.length, 4) assert.strictEqual( sentRequests[2].payload.status, OCPP20FirmwareStatusEnumType.SignatureVerified @@ -598,6 +732,158 @@ await describe('L01/L02 - UpdateFirmware', async () => { assert.strictEqual(sentRequests[5].payload.type, 'FirmwareUpdated') }) }) + + await it('should send InvalidSignature when SimulateSignatureVerificationFailure is true', async t => { + const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + const service = new OCPP20IncomingRequestService() + const { OCPP20VariableManager } = + await import('../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js') + const { AttributeEnumType, OCPP20ComponentName } = + await import('../../../../src/types/index.js') + + // Arrange: Set Device Model variable to simulate verification failure + OCPP20VariableManager.getInstance().setVariables(trackingStation, [ + { + attributeType: AttributeEnumType.Actual, + attributeValue: 'true', + component: { name: OCPP20ComponentName.FirmwareCtrlr as string }, + variable: { name: 'SimulateSignatureVerificationFailure' }, + }, + ]) + + const request: OCPP20UpdateFirmwareRequest = { + firmware: { + location: 'https://firmware.example.com/update.bin', + retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), + signature: 'dGVzdA==', + }, + requestId: 6, + } + const response: OCPP20UpdateFirmwareResponse = { + status: UpdateFirmwareStatusEnumType.Accepted, + } + + await withMockTimers(t, ['setTimeout'], async () => { + service.emit( + OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, + trackingStation, + request, + response + ) + + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 1) + assert.strictEqual( + sentRequests[0].payload.status, + OCPP20FirmwareStatusEnumType.Downloading + ) + + t.mock.timers.tick(2000) + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 2) + assert.strictEqual( + sentRequests[1].payload.status, + OCPP20FirmwareStatusEnumType.Downloaded + ) + + t.mock.timers.tick(500) + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 4) + assert.strictEqual( + sentRequests[2].payload.status, + OCPP20FirmwareStatusEnumType.InvalidSignature + ) + + // Verify lifecycle stops after InvalidSignature: no Installing/Installed emitted + const requestCountAfterInvalidSignature = sentRequests.length + t.mock.timers.tick(10_000) + await flushMicrotasks() + assert.strictEqual(sentRequests.length, requestCountAfterInvalidSignature) + + const securityEventNotifications = sentRequests.filter( + req => req.command === OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION + ) + assert.strictEqual(securityEventNotifications.length, 1) + assert.strictEqual( + securityEventNotifications[0]?.payload?.type, + 'InvalidFirmwareSignature' + ) + }) + }) + + await it('should not send InvalidSignature when SimulateSignatureVerificationFailure is false', async t => { + const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking() + const service = new OCPP20IncomingRequestService() + const { OCPP20VariableManager } = + await import('../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js') + const { AttributeEnumType, OCPP20ComponentName } = + await import('../../../../src/types/index.js') + + // Arrange: Explicitly set variable to false (default behavior) + OCPP20VariableManager.getInstance().setVariables(trackingStation, [ + { + attributeType: AttributeEnumType.Actual, + attributeValue: 'false', + component: { name: OCPP20ComponentName.FirmwareCtrlr as string }, + variable: { name: 'SimulateSignatureVerificationFailure' }, + }, + ]) + + const request: OCPP20UpdateFirmwareRequest = { + firmware: { + location: 'https://firmware.example.com/update.bin', + retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'), + signature: 'dGVzdA==', + }, + requestId: 7, + } + const response: OCPP20UpdateFirmwareResponse = { + status: UpdateFirmwareStatusEnumType.Accepted, + } + + await withMockTimers(t, ['setTimeout'], async () => { + service.emit( + OCPP20IncomingRequestCommand.UPDATE_FIRMWARE, + trackingStation, + request, + response + ) + + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 1) + + t.mock.timers.tick(2000) + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 2) + assert.strictEqual( + sentRequests[1].payload.status, + OCPP20FirmwareStatusEnumType.Downloaded + ) + + t.mock.timers.tick(500) + await flushMicrotasks() + assert.strictEqual(sentRequests.length, 4) + assert.strictEqual( + sentRequests[2].payload.status, + OCPP20FirmwareStatusEnumType.SignatureVerified + ) + assert.strictEqual( + sentRequests[3].payload.status, + OCPP20FirmwareStatusEnumType.Installing + ) + + t.mock.timers.tick(1000) + await flushMicrotasks() + assert.strictEqual(sentRequests[4].payload.status, OCPP20FirmwareStatusEnumType.Installed) + + assert.strictEqual(sentRequests.length, 6) + assert.strictEqual( + sentRequests[5].command, + OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION + ) + assert.strictEqual(sentRequests[5].payload.type, 'FirmwareUpdated') + }) + }) }) }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts index 4db904a4..a7634f72 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts @@ -2419,7 +2419,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Assert const txEvents = mockTracking.sentRequests.filter( - r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string) + r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT ) assert.strictEqual(txEvents.length, 2) @@ -2451,7 +2451,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Assert const txEvents = mockTracking.sentRequests.filter( - r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string) + r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT ) assert.strictEqual(txEvents.length, 2) @@ -2539,7 +2539,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Assert — only Updated(Deauthorized), no Ended const txEvents = mockTracking.sentRequests.filter( - r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string) + r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT ) assert.strictEqual(txEvents.length, 1) assert.strictEqual(txEvents[0].payload.eventType, OCPP20TransactionEventEnumType.Updated) @@ -2639,7 +2639,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Assert const txEvents = mockTracking.sentRequests.filter( - r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string) + r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT ) assert.strictEqual(txEvents.length, 1) @@ -2674,7 +2674,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { // Assert const txEvents = mockTracking.sentRequests.filter( - r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string) + r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT ) assert.strictEqual(txEvents.length, 1) diff --git a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts index 72529a7e..fd1c7ca4 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts @@ -14,9 +14,9 @@ import type { GetCertificateIdUseEnumType, JsonType, OCPP20IdTokenType, - OCPP20RequestCommand, OCSPRequestDataType, } from '../../../../src/types/index.js' +import type { OCPP20RequestCommand } from '../../../../src/types/index.js' import { OCPP20RequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20RequestService.js' import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js' @@ -47,7 +47,7 @@ import { */ export interface CapturedOCPPRequest { /** The OCPP command name (e.g., 'TransactionEvent', 'Heartbeat') */ - command: string + command: OCPP20RequestCommand /** The request payload */ payload: Record } @@ -122,7 +122,7 @@ export function createMockStationWithRequestTracking (): MockStationWithTracking const requestHandlerMock = mock.fn(async (...args: unknown[]) => { sentRequests.push({ - command: args[1] as string, + command: args[1] as OCPP20RequestCommand, payload: args[2] as Record, }) return Promise.resolve({} as EmptyObject) -- 2.43.0