]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ocpp2): add configurable firmware signature verification simulation (L01.FR...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Thu, 26 Mar 2026 19:06:53 +0000 (20:06 +0100)
committerGitHub <noreply@github.com>
Thu, 26 Mar 2026 19:06:53 +0000 (20:06 +0100)
* 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
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts
src/types/ocpp/2.0/Common.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts
tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts

index 6cd19047c42cd5dfcc727030c93b7e1a1f7c2bc2..8a70d1eb4f2e1dc6e949d5a4ffbb83caa478ef46 100644 (file)
--- 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
index 61f42c404624ae18c542af1e5be78f423462bac8..417f6aa8262f7af582625475d33b2da59c9fdab8 100644 (file)
@@ -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<void> {
     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)`
         )
index da809ee36fcb822e2c8fbde4bd74f50de977116e..469944c6e1a37a9612e627e86d7f5c82965f4bb7 100644 (file)
@@ -984,6 +984,23 @@ export const VARIABLE_REGISTRY: Record<string, VariableMetadata> = {
     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,
index 21467ce8f8239433d2012ffe3055358c8b0111bc..8d13fe57260821d25dda3cba4411ed1ea5c92e5e 100644 (file)
@@ -181,6 +181,7 @@ export enum OCPP20ComponentName {
   EVRetentionLock = 'EVRetentionLock',
   EVSE = 'EVSE',
   ExternalTemperatureSensor = 'ExternalTemperatureSensor',
+  FirmwareCtrlr = 'FirmwareCtrlr',
   FiscalMetering = 'FiscalMetering',
   FloodSensor = 'FloodSensor',
   GroundIsolationProtection = 'GroundIsolationProtection',
index 5dff150a7de9fe5deb2da4dccf43e6b5178672e9..9ce65d5343d48a4e303cc753faa0684ca67039ef 100644 (file)
@@ -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')
index 3d92d4519c097cb4dbbb5357e20b48da275d051e..2f8d1357bac8948552bb8d0a515901090b967e6f 100644 (file)
@@ -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')
+        })
+      })
     })
   })
 })
index 4db904a48698f3ad6bb460df6cb1e8d340eb67c4..a7634f729e4de1db5b2c0a5ba84b41e759a80534 100644 (file)
@@ -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)
 
index 72529a7ee6e1571a730e794967ef22b45b501f84..fd1c7ca4b6d6146cb0e704d7ef8c0193a5cca310 100644 (file)
@@ -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<string, unknown>
 }
@@ -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<string, unknown>,
     })
     return Promise.resolve({} as EmptyObject)