]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp20): remediate all OCPP 2.0.1 audit findings (#1726)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sun, 15 Mar 2026 15:10:29 +0000 (16:10 +0100)
committerGitHub <noreply@github.com>
Sun, 15 Mar 2026 15:10:29 +0000 (16:10 +0100)
* fix(ocpp20): add consistent statusInfo to all rejection responses

* fix(ocpp20): cache validatePersistentMappings and fix MinSet/MaxSet atomicity

* feat(ocpp20): implement SetNetworkProfile Accepted path per B09.FR.01

* fix(ocpp20): prevent deletion of ChargingStationCertificate per M04.FR.06

* feat(ocpp20): implement GetLog lifecycle with LogStatusNotification per N01

* fix(ocpp20): implement NotifyCustomerInformation pagination and N09.FR.09 validation

* feat(ocpp20): add X.509 certificate validation per A02.FR.06 and M05

* feat(ocpp20): implement UpdateFirmware lifecycle with status notifications per L01/L02

* feat(ocpp20): generate real PKCS#10 CSR with node:crypto per A02.FR.02

* feat(ocpp20): add firmware signature check, transaction blocking, cancellation

* docs(ocpp20): document OCSP limitation + enforce MaxCertificateChainSize

* style(ocpp20): fix all lint errors and warnings

* [autofix.ci] apply automated fixes

* fix(ocpp20): address PR review findings — enum fix, per-station cache, cancellation status

* [autofix.ci] apply automated fixes

* docs(ocpp20): document simulator limitations for SetNetworkProfile and X.509 validation

* refactor(ocpp20): extract ASN.1 DER utilities to dedicated module

* fix(ocpp20): scope invalidVariables per-station in VariableManager singleton

* [autofix.ci] apply automated fixes

* test(ocpp20): improve test hygiene — extract flushMicrotasks, remove dead code, strengthen spy coverage

* [autofix.ci] apply automated fixes

* fix(ocpp20): replace silent hashId fallback with fail-fast guard in VariableManager

* fix(ocpp20): align B09.FR.02 reasonCode to errata 2025-09 InvalidNetworkConf

* style(ocpp20): normalize spec ref format in additionalInfo to parentheses style

* fix(ocpp20): use request hashAlgorithm in M04.FR.06 guard for algorithm-independent certificate matching

* [autofix.ci] apply automated fixes

* style(ocpp20): harmonize message content — sentence case, consistent punctuation, no em-dash

* fix(ocpp20): unify .catch() log level to logger.error for all fire-and-forget notifications

* fix(ocpp20): remove noisy per-call OCSP stub warning — limitation already documented in JSDoc

* refactor(ocpp20): rename invalidVariablesPerStation to invalidVariables for naming consistency

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
19 files changed:
src/charging-station/ocpp/2.0/Asn1DerUtils.ts [new file with mode: 0644]
src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20RequestService.ts
src/charging-station/ocpp/2.0/OCPP20VariableManager.ts
src/types/ocpp/2.0/Common.ts
tests/charging-station/ocpp/2.0/Asn1DerUtils.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts
tests/charging-station/ocpp/2.0/OCPP20CertificateTestData.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-SetNetworkProfile.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts
tests/charging-station/ocpp/2.0/OCPP20RequestService-SignCertificate.test.ts
tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts
tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts
tests/helpers/TestLifecycleHelpers.ts

diff --git a/src/charging-station/ocpp/2.0/Asn1DerUtils.ts b/src/charging-station/ocpp/2.0/Asn1DerUtils.ts
new file mode 100644 (file)
index 0000000..0cbabb8
--- /dev/null
@@ -0,0 +1,153 @@
+import { createSign, generateKeyPairSync } from 'node:crypto'
+
+// ASN.1 DER encoding helpers for PKCS#10 CSR generation (RFC 2986)
+
+/**
+ * Encode a small non-negative integer in DER.
+ * @param value - Integer value to encode
+ * @returns DER-encoded INTEGER
+ */
+export function derInteger (value: number): Buffer {
+  if (value < 0 || value > 127) {
+    throw new RangeError(`derInteger: value ${String(value)} out of supported range [0, 127]`)
+  }
+  return Buffer.from([0x02, 0x01, value])
+}
+
+/**
+ * Encode DER length in short or long form.
+ * @param length - Length value to encode
+ * @returns DER-encoded length bytes
+ */
+export function derLength (length: number): Buffer {
+  if (length < 0x80) {
+    return Buffer.from([length])
+  }
+  if (length < 0x100) {
+    return Buffer.from([0x81, length])
+  }
+  return Buffer.from([0x82, (length >> 8) & 0xff, length & 0xff])
+}
+
+/**
+ * Wrap content in a DER SEQUENCE (tag 0x30).
+ * @param items - DER-encoded items to include in the sequence
+ * @returns DER-encoded SEQUENCE
+ */
+export function derSequence (...items: Buffer[]): Buffer {
+  const content = Buffer.concat(items)
+  return Buffer.concat([Buffer.from([0x30]), derLength(content.length), content])
+}
+
+/**
+ * Build an X.501 Name (Subject DN) with CN and O attributes.
+ * Structure: SEQUENCE { SET { SEQUENCE { OID, UTF8String } }, ... }
+ * @param cn - Common Name attribute value
+ * @param org - Organization attribute value
+ * @returns DER-encoded X.501 Name
+ */
+function buildSubjectDn (cn: string, org: string): Buffer {
+  const cnRdn = derSet(derSequence(derOid(OID_COMMON_NAME), derUtf8String(cn)))
+  const orgRdn = derSet(derSequence(derOid(OID_ORGANIZATION), derUtf8String(org)))
+  return derSequence(cnRdn, orgRdn)
+}
+
+/**
+ * Encode a DER BIT STRING with zero unused bits.
+ * @param data - Raw bit string content
+ * @returns DER-encoded BIT STRING
+ */
+function derBitString (data: Buffer): Buffer {
+  const content = Buffer.concat([Buffer.from([0x00]), data])
+  return Buffer.concat([Buffer.from([0x03]), derLength(content.length), content])
+}
+
+/**
+ * Encode a DER context-specific constructed tag [tagNumber].
+ * @param tagNumber - Context tag number (0-based)
+ * @param content - Content to wrap
+ * @returns DER-encoded context-tagged content
+ */
+function derContextTag (tagNumber: number, content: Buffer): Buffer {
+  const tag = 0xa0 | tagNumber
+  return Buffer.concat([Buffer.from([tag]), derLength(content.length), content])
+}
+
+/**
+ * Encode a DER OBJECT IDENTIFIER from pre-encoded bytes.
+ * @param oidBytes - Pre-encoded OID byte values
+ * @returns DER-encoded OBJECT IDENTIFIER
+ */
+function derOid (oidBytes: number[]): Buffer {
+  return Buffer.concat([Buffer.from([0x06, oidBytes.length]), Buffer.from(oidBytes)])
+}
+
+/**
+ * Wrap content in a DER SET (tag 0x31).
+ * @param items - DER-encoded items to include in the set
+ * @returns DER-encoded SET
+ */
+function derSet (...items: Buffer[]): Buffer {
+  const content = Buffer.concat(items)
+  return Buffer.concat([Buffer.from([0x31]), derLength(content.length), content])
+}
+
+/**
+ * Encode a DER UTF8String.
+ * @param str - String to encode
+ * @returns DER-encoded UTF8String
+ */
+function derUtf8String (str: string): Buffer {
+  const strBuf = Buffer.from(str, 'utf-8')
+  return Buffer.concat([Buffer.from([0x0c]), derLength(strBuf.length), strBuf])
+}
+
+// Well-known OID encodings
+// 1.2.840.113549.1.1.11 — sha256WithRSAEncryption
+const OID_SHA256_WITH_RSA = [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b]
+// 2.5.4.3 — commonName
+const OID_COMMON_NAME = [0x55, 0x04, 0x03]
+// 2.5.4.10 — organizationName
+const OID_ORGANIZATION = [0x55, 0x04, 0x0a]
+
+/**
+ * Generate a PKCS#10 Certificate Signing Request (RFC 2986) using node:crypto.
+ *
+ * Builds a proper ASN.1 DER-encoded CSR with:
+ * - RSA 2048-bit key pair
+ * - SHA-256 with RSA signature
+ * - Subject DN containing CN={stationId} and O={orgName}
+ * @param cn - Common Name (charging station identifier)
+ * @param org - Organization name
+ * @returns PEM-encoded CSR string with BEGIN/END CERTIFICATE REQUEST markers
+ */
+export function generatePkcs10Csr (cn: string, org: string): string {
+  const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
+
+  const publicKeyDer = publicKey.export({ format: 'der', type: 'spki' })
+
+  // CertificationRequestInfo ::= SEQUENCE { version, subject, subjectPKInfo, attributes }
+  const version = derInteger(0) // v1(0)
+  const subject = buildSubjectDn(cn, org)
+  const attributes = derContextTag(0, Buffer.alloc(0)) // empty attributes [0] IMPLICIT
+  const certificationRequestInfo = derSequence(version, subject, publicKeyDer, attributes)
+
+  // Sign the CertificationRequestInfo with SHA-256
+  const signer = createSign('SHA256')
+  signer.update(certificationRequestInfo)
+  const signature = signer.sign(privateKey)
+
+  // AlgorithmIdentifier ::= SEQUENCE { algorithm OID, parameters NULL }
+  const signatureAlgorithm = derSequence(
+    derOid(OID_SHA256_WITH_RSA),
+    Buffer.from([0x05, 0x00]) // NULL
+  )
+
+  // CertificationRequest ::= SEQUENCE { info, algorithm, signature }
+  const csr = derSequence(certificationRequestInfo, signatureAlgorithm, derBitString(signature))
+
+  // PEM-encode with 64-character line wrapping
+  const base64 = csr.toString('base64')
+  const lines = base64.match(/.{1,64}/g) ?? []
+  return `-----BEGIN CERTIFICATE REQUEST-----\n${lines.join('\n')}\n-----END CERTIFICATE REQUEST-----`
+}
index e4db4f3fcad478301600a5710140260df7d30973..3674a85d0ded06173610e554163ddeecb7e2f35d 100644 (file)
@@ -48,7 +48,8 @@ export interface OCPP20CertificateManagerInterface {
   ): DeleteCertificateResult | Promise<DeleteCertificateResult>
   getInstalledCertificates(
     stationHashId: string,
-    filterTypes?: InstallCertificateUseEnumType[]
+    filterTypes?: InstallCertificateUseEnumType[],
+    hashAlgorithm?: HashAlgorithmEnumType
   ): GetInstalledCertificatesResult | Promise<GetInstalledCertificatesResult>
   storeCertificate(
     stationHashId: string,
@@ -56,6 +57,7 @@ export interface OCPP20CertificateManagerInterface {
     pemData: string
   ): Promise<StoreCertificateResult> | StoreCertificateResult
   validateCertificateFormat(pemData: unknown): boolean
+  validateCertificateX509(pem: string): ValidateCertificateX509Result
 }
 
 /**
@@ -67,6 +69,14 @@ export interface StoreCertificateResult {
   success: boolean
 }
 
+/**
+ * Result type for X.509 certificate validation
+ */
+export interface ValidateCertificateX509Result {
+  reason?: string
+  valid: boolean
+}
+
 /**
  * OCPP 2.0 Certificate Manager
  *
@@ -243,11 +253,13 @@ export class OCPP20CertificateManager {
    * Gets installed certificates for a charging station
    * @param stationHashId - Charging station unique identifier
    * @param filterTypes - Optional array of certificate types to filter
+   * @param hashAlgorithm
    * @returns List of installed certificate hash data chains
    */
   public async getInstalledCertificates (
     stationHashId: string,
-    filterTypes?: InstallCertificateUseEnumType[]
+    filterTypes?: InstallCertificateUseEnumType[],
+    hashAlgorithm?: HashAlgorithmEnumType
   ): Promise<GetInstalledCertificatesResult> {
     const certificateHashDataChain: CertificateHashDataChainType[] = []
 
@@ -279,7 +291,7 @@ export class OCPP20CertificateManager {
           this.validateCertificatePath(filePath, OCPP20CertificateManager.BASE_CERT_PATH)
           try {
             const pemData = await readFile(filePath, 'utf8')
-            const hashData = this.computeCertificateHash(pemData)
+            const hashData = this.computeCertificateHash(pemData, hashAlgorithm)
 
             certificateHashDataChain.push({
               certificateHashData: hashData,
@@ -376,6 +388,45 @@ export class OCPP20CertificateManager {
     )
   }
 
+  /**
+   * Validates a PEM certificate using X.509 structural parsing.
+   * Checks validity period (notBefore/notAfter) and issuer presence per A02.FR.06.
+   *
+   * **Design choice**: Only the first certificate in a PEM chain is validated.
+   * Full chain-of-trust verification (A02.FR.06 hierarchy check) is not implemented —
+   * the simulator performs structural validation only, consistent with the medium-depth
+   * X.509 scope defined in the audit remediation plan.
+   * @param pem - PEM-encoded certificate data (may contain a chain; only first cert is validated)
+   * @returns Validation result with reason on failure
+   */
+  public validateCertificateX509 (pem: string): ValidateCertificateX509Result {
+    try {
+      const firstCertMatch = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/.exec(
+        pem
+      )
+      if (firstCertMatch == null) {
+        return { reason: 'No PEM certificate found', valid: false }
+      }
+      const cert = new X509Certificate(firstCertMatch[0])
+      const now = new Date()
+      if (now < new Date(cert.validFrom)) {
+        return { reason: 'Certificate is not yet valid', valid: false }
+      }
+      if (now > new Date(cert.validTo)) {
+        return { reason: 'Certificate has expired', valid: false }
+      }
+      if (!cert.issuer.trim()) {
+        return { reason: 'Certificate has no issuer', valid: false }
+      }
+      return { valid: true }
+    } catch (error) {
+      return {
+        reason: `Certificate parsing failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
+        valid: false,
+      }
+    }
+  }
+
   /**
    * Computes fallback hash data when X509Certificate parsing fails.
    * Uses the raw PEM content to derive deterministic but non-RFC-compliant hash values.
index 6d179205db12299e703bff98c8494c4d0d25f94c..c41ee905c1e0aadd34d7bc41253a2ad294b5c0dc 100644 (file)
@@ -26,6 +26,7 @@ import {
   type EvseStatus,
   FirmwareStatus,
   FirmwareStatusEnumType,
+  type FirmwareType,
   GenericDeviceModelStatusEnumType,
   GenericStatus,
   GetCertificateIdUseEnumType,
@@ -88,6 +89,8 @@ import {
   OCPP20RequiredVariableName,
   type OCPP20ResetRequest,
   type OCPP20ResetResponse,
+  type OCPP20SecurityEventNotificationRequest,
+  type OCPP20SecurityEventNotificationResponse,
   type OCPP20SetNetworkProfileRequest,
   type OCPP20SetNetworkProfileResponse,
   type OCPP20SetVariablesRequest,
@@ -131,6 +134,7 @@ import {
   sleep,
   validateUUID,
 } from '../../../utils/index.js'
+import { getConfigurationKey } from '../../ConfigurationKeyUtils.js'
 import {
   getIdTagsFile,
   hasPendingReservation,
@@ -155,6 +159,10 @@ const moduleName = 'OCPP20IncomingRequestService'
 export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
   protected payloadValidatorFunctions: Map<OCPP20IncomingRequestCommand, ValidateFunction<JsonType>>
 
+  private activeFirmwareUpdateAbortController: AbortController | undefined
+
+  private activeFirmwareUpdateRequestId: number | undefined
+
   private readonly incomingRequestHandlers: Map<
     OCPP20IncomingRequestCommand,
     IncomingRequestHandler
@@ -277,7 +285,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
           this.simulateFirmwareUpdateLifecycle(
             chargingStation,
             request.requestId,
-            request.firmware.signature
+            request.firmware
           ).catch((error: unknown) => {
             logger.error(
               `${chargingStation.logPrefix()} ${moduleName}.constructor: UpdateFirmware lifecycle error:`,
@@ -1041,6 +1049,13 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     return reportData
   }
 
+  private clearActiveFirmwareUpdate (requestId: number): void {
+    if (this.activeFirmwareUpdateRequestId === requestId) {
+      this.activeFirmwareUpdateAbortController = undefined
+      this.activeFirmwareUpdateRequestId = undefined
+    }
+  }
+
   private getTxUpdatedInterval (chargingStation: ChargingStation): number {
     const variableManager = OCPP20VariableManager.getInstance()
     const results = variableManager.getVariables(chargingStation, [
@@ -1182,6 +1197,51 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       }
     }
 
+    // A02.FR.16: Enforce MaxCertificateChainSize — reject if chain exceeds configured limit
+    const maxChainSizeKey = getConfigurationKey(chargingStation, 'MaxCertificateChainSize')
+    if (maxChainSizeKey?.value != null) {
+      const maxChainSize = parseInt(maxChainSizeKey.value, 10)
+      if (!isNaN(maxChainSize) && maxChainSize > 0) {
+        const chainByteSize = Buffer.byteLength(certificateChain, 'utf8')
+        if (chainByteSize > maxChainSize) {
+          logger.warn(
+            `${chargingStation.logPrefix()} ${moduleName}.handleRequestCertificateSigned: Certificate chain size ${chainByteSize.toString()} bytes exceeds MaxCertificateChainSize ${maxChainSize.toString()} bytes`
+          )
+          return {
+            status: GenericStatus.Rejected,
+            statusInfo: {
+              additionalInfo: `Certificate chain size (${chainByteSize.toString()} bytes) exceeds MaxCertificateChainSize (${maxChainSize.toString()} bytes)`,
+              reasonCode: ReasonCodeEnumType.InvalidCertificate,
+            },
+          }
+        }
+      }
+    }
+
+    const x509Result = chargingStation.certificateManager.validateCertificateX509(certificateChain)
+    if (!x509Result.valid) {
+      logger.warn(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestCertificateSigned: X.509 validation failed: ${x509Result.reason ?? 'Unknown'}`
+      )
+      this.sendSecurityEventNotification(
+        chargingStation,
+        'InvalidChargingStationCertificate',
+        `X.509 validation failed: ${x509Result.reason ?? 'Unknown'}`
+      ).catch((error: unknown) => {
+        logger.error(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestCertificateSigned: SecurityEventNotification failed:`,
+          error
+        )
+      })
+      return {
+        status: GenericStatus.Rejected,
+        statusInfo: {
+          additionalInfo: x509Result.reason ?? 'Certificate X.509 validation failed',
+          reasonCode: ReasonCodeEnumType.InvalidCertificate,
+        },
+      }
+    }
+
     try {
       const result = chargingStation.certificateManager.storeCertificate(
         chargingStation.stationInfo?.hashId ?? '',
@@ -1318,6 +1378,28 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       `${chargingStation.logPrefix()} ${moduleName}.handleRequestCustomerInformation: Received CustomerInformation request with clear=${commandPayload.clear.toString()}, report=${commandPayload.report.toString()}`
     )
 
+    // N09.FR.09: Exactly one of {idToken, customerCertificate, customerIdentifier} must be provided when report=true
+    if (commandPayload.report) {
+      const identifierCount = [
+        commandPayload.idToken,
+        commandPayload.customerCertificate,
+        commandPayload.customerIdentifier,
+      ].filter(id => id != null).length
+
+      if (identifierCount !== 1) {
+        logger.warn(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestCustomerInformation: N09.FR.09 violation - expected exactly 1 customer identifier when report=true, got ${identifierCount.toString()}`
+        )
+        return {
+          status: CustomerInformationStatusEnumType.Invalid,
+          statusInfo: {
+            additionalInfo: 'Exactly one customer identifier must be provided when report=true',
+            reasonCode: ReasonCodeEnumType.InvalidValue,
+          },
+        }
+      }
+    }
+
     if (commandPayload.clear) {
       logger.info(
         `${chargingStation.logPrefix()} ${moduleName}.handleRequestCustomerInformation: Clear request accepted (simulator has no persistent customer data)`
@@ -1341,6 +1423,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     )
     return {
       status: CustomerInformationStatusEnumType.Rejected,
+      statusInfo: {
+        additionalInfo: 'Neither clear nor report flag is set in CustomerInformation request',
+        reasonCode: ReasonCodeEnumType.InvalidValue,
+      },
     }
   }
 
@@ -1367,6 +1453,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
   /**
    * Handles OCPP 2.0 DeleteCertificate request from central system
    * Deletes a certificate matching the provided hash data from the charging station
+   * Per M04.FR.06: ChargingStationCertificate cannot be deleted via DeleteCertificateRequest
    * @param chargingStation - The charging station instance processing the request
    * @param commandPayload - DeleteCertificate request payload with certificate hash data
    * @returns Promise resolving to DeleteCertificateResponse with status
@@ -1391,6 +1478,38 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     }
 
     try {
+      // M04.FR.06: Check if the certificate to delete is a ChargingStationCertificate
+      // If so, reject the deletion request
+      // Get all installed certificates using the request's hashAlgorithm for consistent comparison
+      const installedCerts = chargingStation.certificateManager.getInstalledCertificates(
+        chargingStation.stationInfo?.hashId ?? '',
+        undefined,
+        certificateHashData.hashAlgorithm
+      )
+
+      const installedCertsResult =
+        installedCerts instanceof Promise ? await installedCerts : installedCerts
+
+      for (const certChain of installedCertsResult.certificateHashDataChain) {
+        const certHash = certChain.certificateHashData
+        if (
+          certHash.serialNumber === certificateHashData.serialNumber &&
+          certHash.issuerNameHash === certificateHashData.issuerNameHash &&
+          certHash.issuerKeyHash === certificateHashData.issuerKeyHash
+        ) {
+          logger.warn(
+            `${chargingStation.logPrefix()} ${moduleName}.handleRequestDeleteCertificate: Attempted to delete ChargingStationCertificate (M04.FR.06)`
+          )
+          return {
+            status: DeleteCertificateStatusEnumType.Failed,
+            statusInfo: {
+              additionalInfo: 'ChargingStationCertificate cannot be deleted (M04.FR.06)',
+              reasonCode: ReasonCodeEnumType.NotSupported,
+            },
+          }
+        }
+      }
+
       const result = chargingStation.certificateManager.deleteCertificate(
         chargingStation.stationInfo?.hashId ?? '',
         certificateHashData
@@ -1634,6 +1753,20 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       }
     }
 
+    const x509Result = chargingStation.certificateManager.validateCertificateX509(certificate)
+    if (!x509Result.valid) {
+      logger.warn(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestInstallCertificate: X.509 validation failed for type ${certificateType}: ${x509Result.reason ?? 'Unknown'}`
+      )
+      return {
+        status: InstallCertificateStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: x509Result.reason ?? 'Certificate X.509 validation failed',
+          reasonCode: ReasonCodeEnumType.InvalidCertificate,
+        },
+      }
+    }
+
     try {
       const rawResult = chargingStation.certificateManager.storeCertificate(
         chargingStation.stationInfo?.hashId ?? '',
@@ -1902,12 +2035,16 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
   }
 
   /**
-   * Handles OCPP 2.0.1 SetNetworkProfile request from central system
-   * Per TC_B_43_CS: CS must respond to SetNetworkProfile at minimum with Rejected
-   * The simulator does not support network profile switching
+   * Handles OCPP 2.0.1 SetNetworkProfile request from central system.
+   * Per B09.FR.01: Validates configurationSlot and connectionData, returns Accepted for valid requests.
+   * The simulator accepts the request but does not perform actual network profile switching.
+   *
+   * **Simulator limitations** (documented, not implemented):
+   * - B09.FR.04: securityProfile downgrade detection requires persistent SecurityProfile state
+   * - B09.FR.05: configurationSlot vs NetworkConfigurationPriority cross-check requires device model query
    * @param chargingStation - The charging station instance
    * @param commandPayload - The SetNetworkProfile request payload
-   * @returns SetNetworkProfileResponse with Rejected status
+   * @returns SetNetworkProfileResponse with Accepted or Rejected status
    */
   private handleRequestSetNetworkProfile (
     chargingStation: ChargingStation,
@@ -1916,13 +2053,31 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     logger.debug(
       `${chargingStation.logPrefix()} ${moduleName}.handleRequestSetNetworkProfile: Received SetNetworkProfile request`
     )
-    // Per TC_B_43_CS: CS must respond to SetNetworkProfile at minimum with Rejected
+
+    // Validate configurationSlot is a positive integer (B09.FR.02)
+    if (
+      !Number.isInteger(commandPayload.configurationSlot) ||
+      commandPayload.configurationSlot <= 0
+    ) {
+      logger.warn(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestSetNetworkProfile: Invalid configurationSlot: ${commandPayload.configurationSlot.toString()}`
+      )
+      return {
+        status: SetNetworkProfileStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: 'ConfigurationSlot must be a positive integer',
+          reasonCode: ReasonCodeEnumType.InvalidNetworkConf,
+        },
+      }
+    }
+
+    // B09.FR.04/FR.05: securityProfile downgrade and slot-in-priority checks not implemented
+    // (simulator limitation — would require persistent device model state)
+    logger.info(
+      `${chargingStation.logPrefix()} ${moduleName}.handleRequestSetNetworkProfile: Accepting SetNetworkProfile request for slot ${commandPayload.configurationSlot.toString()}`
+    )
     return {
-      status: SetNetworkProfileStatusEnumType.Rejected,
-      statusInfo: {
-        additionalInfo: 'Simulator does not support network profile configuration',
-        reasonCode: ReasonCodeEnumType.UnsupportedRequest,
-      },
+      status: SetNetworkProfileStatusEnumType.Accepted,
     }
   }
 
@@ -1991,6 +2146,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       )
       return {
         status: RequestStartStopStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: `Connector ${connectorId.toString()} already has an active transaction`,
+          reasonCode: ReasonCodeEnumType.TxInProgress,
+        },
         transactionId: generateUUID(),
       }
     }
@@ -2005,6 +2164,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       )
       return {
         status: RequestStartStopStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: 'Authorization error occurred',
+          reasonCode: ReasonCodeEnumType.InternalError,
+        },
         transactionId: generateUUID(),
       }
     }
@@ -2015,6 +2178,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       )
       return {
         status: RequestStartStopStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: `IdToken ${idToken.idToken} is not authorized`,
+          reasonCode: ReasonCodeEnumType.InvalidIdToken,
+        },
         transactionId: generateUUID(),
       }
     }
@@ -2030,6 +2197,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         )
         return {
           status: RequestStartStopStatusEnumType.Rejected,
+          statusInfo: {
+            additionalInfo: 'Group authorization error occurred',
+            reasonCode: ReasonCodeEnumType.InternalError,
+          },
           transactionId: generateUUID(),
         }
       }
@@ -2040,6 +2211,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         )
         return {
           status: RequestStartStopStatusEnumType.Rejected,
+          statusInfo: {
+            additionalInfo: `GroupIdToken ${groupIdToken.idToken} is not authorized`,
+            reasonCode: ReasonCodeEnumType.InvalidIdToken,
+          },
           transactionId: generateUUID(),
         }
       }
@@ -2055,6 +2230,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         )
         return {
           status: RequestStartStopStatusEnumType.Rejected,
+          statusInfo: {
+            additionalInfo: 'ChargingProfile must have purpose TxProfile',
+            reasonCode: ReasonCodeEnumType.InvalidProfile,
+          },
           transactionId: generateUUID(),
         }
       }
@@ -2066,6 +2245,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         )
         return {
           status: RequestStartStopStatusEnumType.Rejected,
+          statusInfo: {
+            additionalInfo: 'ChargingProfile transactionId must not be set',
+            reasonCode: ReasonCodeEnumType.InvalidValue,
+          },
           transactionId: generateUUID(),
         }
       }
@@ -2079,6 +2262,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         )
         return {
           status: RequestStartStopStatusEnumType.Rejected,
+          statusInfo: {
+            additionalInfo: 'Charging profile validation error',
+            reasonCode: ReasonCodeEnumType.InternalError,
+          },
           transactionId: generateUUID(),
         }
       }
@@ -2088,6 +2275,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         )
         return {
           status: RequestStartStopStatusEnumType.Rejected,
+          statusInfo: {
+            additionalInfo: 'Invalid charging profile',
+            reasonCode: ReasonCodeEnumType.InvalidProfile,
+          },
           transactionId: generateUUID(),
         }
       }
@@ -2143,6 +2334,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       )
       return {
         status: RequestStartStopStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: 'Error starting transaction',
+          reasonCode: ReasonCodeEnumType.InternalError,
+        },
         transactionId: generateUUID(),
       }
     }
@@ -2163,6 +2358,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       )
       return {
         status: RequestStartStopStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: 'Invalid transaction ID format',
+          reasonCode: ReasonCodeEnumType.InvalidValue,
+        },
       }
     }
 
@@ -2173,6 +2372,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       )
       return {
         status: RequestStartStopStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: `Transaction ID ${transactionId as string} does not exist`,
+          reasonCode: ReasonCodeEnumType.TxNotFound,
+        },
       }
     }
 
@@ -2183,6 +2386,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       )
       return {
         status: RequestStartStopStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: `Transaction ID ${transactionId as string} does not exist on any connector`,
+          reasonCode: ReasonCodeEnumType.TxNotFound,
+        },
       }
     }
 
@@ -2207,6 +2414,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       )
       return {
         status: RequestStartStopStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: 'Remote stop transaction rejected',
+          reasonCode: ReasonCodeEnumType.Unspecified,
+        },
       }
     } catch (error) {
       logger.error(
@@ -2215,6 +2426,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       )
       return {
         status: RequestStartStopStatusEnumType.Rejected,
+        statusInfo: {
+          additionalInfo: 'Error occurred during remote stop transaction',
+          reasonCode: ReasonCodeEnumType.InternalError,
+        },
       }
     }
   }
@@ -2414,6 +2629,69 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       `${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: Received UpdateFirmware request with requestId ${requestId.toString()} for location '${firmware.location}'`
     )
 
+    // C10: Validate signing certificate PEM format if present
+    if (firmware.signingCertificate != null && firmware.signingCertificate.trim() !== '') {
+      if (
+        !hasCertificateManager(chargingStation) ||
+        !chargingStation.certificateManager.validateCertificateFormat(firmware.signingCertificate)
+      ) {
+        logger.warn(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: Invalid PEM format for signing certificate`
+        )
+        this.sendSecurityEventNotification(
+          chargingStation,
+          'InvalidFirmwareSigningCertificate',
+          `Invalid signing certificate PEM for requestId ${requestId.toString()}`
+        ).catch((error: unknown) => {
+          logger.error(
+            `${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: SecurityEventNotification error:`,
+            error
+          )
+        })
+        return {
+          status: UpdateFirmwareStatusEnumType.InvalidCertificate,
+        }
+      }
+    }
+
+    // C11: Reject if any EVSE has active transactions
+    for (const [evseId, evseStatus] of chargingStation.evses) {
+      if (evseId > 0 && this.hasEvseActiveTransactions(evseStatus)) {
+        logger.warn(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: Rejected - EVSE ${evseId.toString()} has active transactions`
+        )
+        return {
+          status: UpdateFirmwareStatusEnumType.Rejected,
+          statusInfo: {
+            additionalInfo: 'Active transactions must complete before firmware update',
+            reasonCode: ReasonCodeEnumType.TxInProgress,
+          },
+        }
+      }
+    }
+
+    // H10: Cancel any in-progress firmware update
+    if (this.activeFirmwareUpdateAbortController != null) {
+      const previousRequestId = this.activeFirmwareUpdateRequestId
+      this.activeFirmwareUpdateAbortController.abort()
+      this.activeFirmwareUpdateAbortController = undefined
+      this.activeFirmwareUpdateRequestId = undefined
+      logger.info(
+        `${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: Canceled previous firmware update requestId ${String(previousRequestId)}`
+      )
+      // Send AcceptedCanceled notification for the old firmware update
+      this.sendFirmwareStatusNotification(
+        chargingStation,
+        FirmwareStatusEnumType.AcceptedCanceled,
+        previousRequestId ?? 0
+      ).catch((error: unknown) => {
+        logger.error(
+          `${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: AcceptedCanceled notification error:`,
+          error
+        )
+      })
+    }
+
     return {
       status: UpdateFirmwareStatusEnumType.Accepted,
     }
@@ -2582,6 +2860,15 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     }
   }
 
+  private isValidFirmwareLocation (location: string): boolean {
+    try {
+      const url = new URL(location)
+      return url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ftp:'
+    } catch {
+      return false
+    }
+  }
+
   /**
    * Reset connector status on start transaction error
    * @param chargingStation - The charging station instance
@@ -2770,20 +3057,37 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     chargingStation: ChargingStation,
     requestId: number
   ): Promise<void> {
-    const notifyCustomerInformationRequest: OCPP20NotifyCustomerInformationRequest = {
-      data: '',
-      generatedAt: new Date(),
-      requestId,
-      seqNo: 0,
-      tbc: false,
+    // Simulator has no persistent customer data, so send empty data.
+    // Uses pagination pattern (seqNo/tbc) consistent with sendNotifyReportRequest.
+    const dataChunks = ['']
+
+    for (let seqNo = 0; seqNo < dataChunks.length; seqNo++) {
+      const isLastChunk = seqNo === dataChunks.length - 1
+
+      const notifyCustomerInformationRequest: OCPP20NotifyCustomerInformationRequest = {
+        data: dataChunks[seqNo],
+        generatedAt: new Date(),
+        requestId,
+        seqNo,
+        tbc: !isLastChunk,
+      }
+
+      await chargingStation.ocppRequestService.requestHandler<
+        OCPP20NotifyCustomerInformationRequest,
+        OCPP20NotifyCustomerInformationResponse
+      >(
+        chargingStation,
+        OCPP20RequestCommand.NOTIFY_CUSTOMER_INFORMATION,
+        notifyCustomerInformationRequest
+      )
+
+      logger.debug(
+        `${chargingStation.logPrefix()} ${moduleName}.sendNotifyCustomerInformation: NotifyCustomerInformation sent seqNo=${seqNo.toString()} for requestId ${requestId.toString()} (tbc=${(!isLastChunk).toString()})`
+      )
     }
-    await chargingStation.ocppRequestService.requestHandler<
-      OCPP20NotifyCustomerInformationRequest,
-      OCPP20NotifyCustomerInformationResponse
-    >(
-      chargingStation,
-      OCPP20RequestCommand.NOTIFY_CUSTOMER_INFORMATION,
-      notifyCustomerInformationRequest
+
+    logger.debug(
+      `${chargingStation.logPrefix()} ${moduleName}.sendNotifyCustomerInformation: Completed NotifyCustomerInformation for requestId ${requestId.toString()} in ${dataChunks.length.toString()} message(s)`
     )
   }
 
@@ -2836,25 +3140,79 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     this.reportDataCache.delete(requestId)
   }
 
+  private sendSecurityEventNotification (
+    chargingStation: ChargingStation,
+    type: string,
+    techInfo?: string
+  ): Promise<OCPP20SecurityEventNotificationResponse> {
+    return chargingStation.ocppRequestService.requestHandler<
+      OCPP20SecurityEventNotificationRequest,
+      OCPP20SecurityEventNotificationResponse
+    >(chargingStation, OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION, {
+      timestamp: new Date(),
+      type,
+      ...(techInfo !== undefined && { techInfo }),
+    })
+  }
+
   /**
-   * Simulates a firmware update lifecycle through status progression using chained setTimeout calls.
-   * Sequence: Downloading → Downloaded → [SignatureVerified if signature present] → Installing → Installed
+   * Simulates a firmware update lifecycle through status progression per OCPP 2.0.1 L01/L02.
+   * Sequence: [DownloadScheduled] → Downloading → Downloaded/DownloadFailed →
+   *           [SignatureVerified] → [InstallScheduled] → Installing → Installed
    * @param chargingStation - The charging station instance
    * @param requestId - The request ID from the UpdateFirmware request
-   * @param signature - Optional firmware signature; triggers SignatureVerified step if present
+   * @param firmware - The firmware details including location, dates, and optional signature
    */
   private async simulateFirmwareUpdateLifecycle (
     chargingStation: ChargingStation,
     requestId: number,
-    signature?: string
+    firmware: FirmwareType
   ): Promise<void> {
+    const { installDateTime, location, retrieveDateTime, signature } = firmware
+
+    // H10: Set up abort controller for cancellation support
+    const abortController = new AbortController()
+    this.activeFirmwareUpdateAbortController = abortController
+    this.activeFirmwareUpdateRequestId = requestId
+
+    const checkAborted = (): boolean => abortController.signal.aborted
+
+    // C12: If retrieveDateTime is in the future, send DownloadScheduled and wait
+    const now = Date.now()
+    const retrieveTime = new Date(retrieveDateTime).getTime()
+    if (retrieveTime > now) {
+      await this.sendFirmwareStatusNotification(
+        chargingStation,
+        FirmwareStatusEnumType.DownloadScheduled,
+        requestId
+      )
+      await sleep(retrieveTime - now)
+      if (checkAborted()) return
+    }
+
     await this.sendFirmwareStatusNotification(
       chargingStation,
       FirmwareStatusEnumType.Downloading,
       requestId
     )
 
-    await sleep(1000)
+    await sleep(2000)
+    if (checkAborted()) return
+
+    // H9: If firmware location is empty or malformed, send DownloadFailed and stop
+    if (location.trim() === '' || !this.isValidFirmwareLocation(location)) {
+      await this.sendFirmwareStatusNotification(
+        chargingStation,
+        FirmwareStatusEnumType.DownloadFailed,
+        requestId
+      )
+      logger.warn(
+        `${chargingStation.logPrefix()} ${moduleName}.simulateFirmwareUpdateLifecycle: Download failed for requestId ${requestId.toString()} - invalid location '${location}'`
+      )
+      this.clearActiveFirmwareUpdate(requestId)
+      return
+    }
+
     await this.sendFirmwareStatusNotification(
       chargingStation,
       FirmwareStatusEnumType.Downloaded,
@@ -2863,6 +3221,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
 
     if (signature != null) {
       await sleep(500)
+      if (checkAborted()) return
       await this.sendFirmwareStatusNotification(
         chargingStation,
         FirmwareStatusEnumType.SignatureVerified,
@@ -2870,7 +3229,21 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
       )
     }
 
-    await sleep(1000)
+    // C12: If installDateTime is in the future, send InstallScheduled and wait
+    if (installDateTime != null) {
+      const installTime = new Date(installDateTime).getTime()
+      const currentTime = Date.now()
+      if (installTime > currentTime) {
+        await this.sendFirmwareStatusNotification(
+          chargingStation,
+          FirmwareStatusEnumType.InstallScheduled,
+          requestId
+        )
+        await sleep(installTime - currentTime)
+        if (checkAborted()) return
+      }
+    }
+
     await this.sendFirmwareStatusNotification(
       chargingStation,
       FirmwareStatusEnumType.Installing,
@@ -2878,12 +3251,21 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     )
 
     await sleep(1000)
+    if (checkAborted()) return
     await this.sendFirmwareStatusNotification(
       chargingStation,
       FirmwareStatusEnumType.Installed,
       requestId
     )
 
+    // H11: Send SecurityEventNotification for successful firmware update
+    await this.sendSecurityEventNotification(
+      chargingStation,
+      'FirmwareUpdated',
+      `Firmware update completed for requestId ${requestId.toString()}`
+    )
+
+    this.clearActiveFirmwareUpdate(requestId)
     logger.info(
       `${chargingStation.logPrefix()} ${moduleName}.simulateFirmwareUpdateLifecycle: Firmware update simulation completed for requestId ${requestId.toString()}`
     )
index 5be8edcf29d875aa968280bd7d4e910c6f46bd62..aeb14a0e7192f1b4c527ca2a9f61ae568b2dece3 100644 (file)
@@ -1,7 +1,5 @@
 import type { ValidateFunction } from 'ajv'
 
-import { generateKeyPairSync } from 'node:crypto'
-
 import type { ChargingStation } from '../../../charging-station/index.js'
 import type { OCPPResponseService } from '../OCPPResponseService.js'
 
@@ -38,6 +36,7 @@ import {
 } from '../../../types/index.js'
 import { generateUUID, logger } from '../../../utils/index.js'
 import { OCPPRequestService } from '../OCPPRequestService.js'
+import { generatePkcs10Csr } from './Asn1DerUtils.js'
 import { OCPP20Constants } from './OCPP20Constants.js'
 import { OCPP20ServiceUtils } from './OCPP20ServiceUtils.js'
 
@@ -191,8 +190,11 @@ export class OCPP20RequestService extends OCPPRequestService {
    * This is used to validate certificates during ISO 15118 communication
    * before accepting them for charging authorization.
    *
-   * Note: This is a stub implementation for simulator testing. No real OCSP
-   * network calls are made - the CSMS provides the response.
+   * **Simulator limitation**: This is a stub implementation — no real OCSP network calls are
+   * made. The method forwards the OCSP request data to the CSMS, which provides the certificate
+   * revocation status in its response. In a production charging station, the CS or CSMS would
+   * contact an external OCSP responder to verify certificate validity in real time. Full OCSP
+   * integration would require external OCSP responder configuration and network access.
    * @param chargingStation - The charging station requesting the status
    * @param ocspRequestData - OCSP request data including certificate hash and responder URL
    * @returns Promise resolving to the CSMS response with OCSP result
@@ -495,18 +497,13 @@ export class OCPP20RequestService extends OCPPRequestService {
   /**
    * Request certificate signing from the CSMS.
    *
-   * Generates a Certificate Signing Request (CSR) using the charging station's
-   * certificate manager and sends it to the CSMS for signing. Supports both
-   * ChargingStationCertificate and V2GCertificate types.
-   *
-   * IMPORTANT: This implementation generates a MOCK CSR for simulator testing purposes.
-   * It is NOT a cryptographically valid PKCS#10 CSR and MUST NOT be used in production.
-   * The generated CSR structure is simplified (JSON-based) for OCPP message testing only.
-   * Real CSMS expecting valid PKCS#10 CSR will reject this format.
+   * Generates a PKCS#10 Certificate Signing Request (RFC 2986) with a real RSA 2048-bit
+   * key pair and SHA-256 signature, then sends it to the CSMS for signing per A02.FR.02.
+   * Supports both ChargingStationCertificate and V2GCertificate types.
    * @param chargingStation - The charging station requesting the certificate
    * @param certificateType - Optional certificate type (ChargingStationCertificate or V2GCertificate)
    * @returns Promise resolving to the CSMS response with Accepted or Rejected status
-   * @throws {OCPPError} When certificate manager is unavailable or CSR generation fails
+   * @throws {OCPPError} When CSR generation fails
    */
   public async requestSignCertificate (
     chargingStation: ChargingStation,
@@ -518,32 +515,13 @@ export class OCPP20RequestService extends OCPPRequestService {
 
     let csr: string
     try {
-      const { publicKey } = generateKeyPairSync('rsa', {
-        modulusLength: 2048,
-        privateKeyEncoding: { format: 'pem', type: 'pkcs8' },
-        publicKeyEncoding: { format: 'pem', type: 'spki' },
-      })
-
       const configKey = chargingStation.ocppConfiguration?.configurationKey?.find(
         key => key.key === 'SecurityCtrlr.OrganizationName'
       )
       const orgName = configKey?.value ?? 'Unknown'
       const stationId = chargingStation.stationInfo?.chargingStationId ?? 'Unknown'
-      const subject = `CN=${stationId},O=${orgName}`
-
-      // Generate simplified mock CSR for simulator testing
-      // WARNING: This is NOT a cryptographically valid PKCS#10 CSR
-      // Structure: JSON with subject, publicKey, timestamp (NOT ASN.1 DER)
-      const mockCsrData = {
-        algorithm: 'RSA-SHA256',
-        keySize: 2048,
-        publicKey: publicKey.replace(/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\n/g, ''),
-        subject,
-        timestamp: new Date().toISOString(),
-      }
 
-      const csrBase64 = Buffer.from(JSON.stringify(mockCsrData)).toString('base64')
-      csr = `-----BEGIN CERTIFICATE REQUEST-----\n${csrBase64}\n-----END CERTIFICATE REQUEST-----`
+      csr = generatePkcs10Csr(stationId, orgName)
     } catch (error) {
       const errorMsg = `Failed to generate CSR: ${error instanceof Error ? error.message : 'Unknown error'}`
       logger.error(
index b11338680d5bb9dac9d688cef32da73a53288215..0d61f91269716877107ccf8aee9dc2fd95346aab 100644 (file)
@@ -59,10 +59,11 @@ export class OCPP20VariableManager {
     Object.keys(VARIABLE_REGISTRY).map(k => k.split('::')[0])
   )
 
-  private readonly invalidVariables = new Set<string>() // composite key (lower case)
-  private readonly maxSetOverrides = new Map<string, string>() // composite key (lower case)
-  private readonly minSetOverrides = new Map<string, string>() // composite key (lower case)
-  private readonly runtimeOverrides = new Map<string, string>() // composite key (lower case)
+  private readonly invalidVariables = new Map<string, Set<string>>() // stationId → composite keys (lower case)
+  private readonly maxSetOverrides = new Map<string, string>() // composite key (lower case) → value
+  private readonly minSetOverrides = new Map<string, string>() // composite key (lower case) → value
+  private readonly runtimeOverrides = new Map<string, string>() // composite key (lower case) → value
+  private readonly validatedStations = new Set<string>() // stationId
 
   private constructor () {
     /* This is intentional */
@@ -103,6 +104,16 @@ export class OCPP20VariableManager {
     return results
   }
 
+  public invalidateMappingsCache (stationId?: string): void {
+    if (stationId != null) {
+      this.validatedStations.delete(stationId)
+      this.invalidVariables.delete(stationId)
+    } else {
+      this.validatedStations.clear()
+      this.invalidVariables.clear()
+    }
+  }
+
   public resetRuntimeOverrides (): void {
     this.runtimeOverrides.clear()
   }
@@ -112,6 +123,46 @@ export class OCPP20VariableManager {
     setVariableData: OCPP20SetVariableDataType[]
   ): OCPP20SetVariableResultType[] {
     this.validatePersistentMappings(chargingStation)
+
+    // Collect paired MinSet/MaxSet entries for atomic cross-validation
+    const pairedBounds = new Map<string, { maxValue?: string; minValue?: string }>()
+    for (const variableData of setVariableData) {
+      const resolvedAttr = variableData.attributeType ?? AttributeEnumType.Actual
+      if (resolvedAttr !== AttributeEnumType.MinSet && resolvedAttr !== AttributeEnumType.MaxSet) {
+        continue
+      }
+      const varKey = buildCaseInsensitiveCompositeKey(
+        variableData.component.name,
+        variableData.component.instance,
+        variableData.variable.name
+      )
+      const entry = pairedBounds.get(varKey) ?? {}
+      if (resolvedAttr === AttributeEnumType.MinSet) {
+        entry.minValue = variableData.attributeValue
+      } else {
+        entry.maxValue = variableData.attributeValue
+      }
+      pairedBounds.set(varKey, entry)
+    }
+
+    // Pre-apply coherent MinSet/MaxSet pairs so per-item cross-check sees paired values
+    const savedOverrides = new Map<
+      string,
+      { prevMax: string | undefined; prevMin: string | undefined }
+    >()
+    for (const [varKey, pair] of pairedBounds) {
+      if (pair.minValue == null || pair.maxValue == null) continue
+      const newMin = convertToIntOrNaN(pair.minValue)
+      const newMax = convertToIntOrNaN(pair.maxValue)
+      if (Number.isNaN(newMin) || Number.isNaN(newMax) || newMin > newMax) continue
+      savedOverrides.set(varKey, {
+        prevMax: this.maxSetOverrides.get(varKey),
+        prevMin: this.minSetOverrides.get(varKey),
+      })
+      this.minSetOverrides.set(varKey, pair.minValue)
+      this.maxSetOverrides.set(varKey, pair.maxValue)
+    }
+
     const results: OCPP20SetVariableResultType[] = []
     for (const variableData of setVariableData) {
       try {
@@ -134,11 +185,63 @@ export class OCPP20VariableManager {
         })
       }
     }
+
+    // Rollback pre-applied overrides for rejected items
+    for (const [varKey, saved] of savedOverrides) {
+      let minRejected = false
+      let maxRejected = false
+      for (let i = 0; i < setVariableData.length; i++) {
+        const data = setVariableData[i]
+        const resolvedAttr = data.attributeType ?? AttributeEnumType.Actual
+        if (
+          resolvedAttr !== AttributeEnumType.MinSet &&
+          resolvedAttr !== AttributeEnumType.MaxSet
+        ) {
+          continue
+        }
+        const itemKey = buildCaseInsensitiveCompositeKey(
+          data.component.name,
+          data.component.instance,
+          data.variable.name
+        )
+        if (itemKey !== varKey) continue
+        if (
+          resolvedAttr === AttributeEnumType.MinSet &&
+          results[i].attributeStatus !== SetVariableStatusEnumType.Accepted
+        ) {
+          minRejected = true
+        }
+        if (
+          resolvedAttr === AttributeEnumType.MaxSet &&
+          results[i].attributeStatus !== SetVariableStatusEnumType.Accepted
+        ) {
+          maxRejected = true
+        }
+      }
+      if (minRejected) {
+        if (saved.prevMin != null) {
+          this.minSetOverrides.set(varKey, saved.prevMin)
+        } else {
+          this.minSetOverrides.delete(varKey)
+        }
+      }
+      if (maxRejected) {
+        if (saved.prevMax != null) {
+          this.maxSetOverrides.set(varKey, saved.prevMax)
+        } else {
+          this.maxSetOverrides.delete(varKey)
+        }
+      }
+    }
+
     return results
   }
 
   public validatePersistentMappings (chargingStation: ChargingStation): void {
-    this.invalidVariables.clear()
+    const stationId = this.getStationId(chargingStation)
+    if (this.validatedStations.has(stationId)) return
+    const invalidVariables = this.getInvalidVariables(stationId)
+    invalidVariables.clear()
     for (const metaKey of Object.keys(VARIABLE_REGISTRY)) {
       const variableMetadata = VARIABLE_REGISTRY[metaKey]
       // Enforce persistent non-write-only variables across components
@@ -180,13 +283,31 @@ export class OCPP20VariableManager {
             `${chargingStation.logPrefix()} Added missing configuration key for variable '${configurationKeyName}' with default '${defaultValue}'`
           )
         } else {
-          this.invalidVariables.add(variableKey)
+          invalidVariables.add(variableKey)
           logger.error(
             `${chargingStation.logPrefix()} Missing configuration key mapping and no default for variable '${configurationKeyName}'`
           )
         }
       }
     }
+    this.validatedStations.add(stationId)
+  }
+
+  private getInvalidVariables (stationId: string): Set<string> {
+    let set = this.invalidVariables.get(stationId)
+    if (set == null) {
+      set = new Set<string>()
+      this.invalidVariables.set(stationId, set)
+    }
+    return set
+  }
+
+  private getStationId (chargingStation: ChargingStation): string {
+    const stationId = chargingStation.stationInfo?.hashId
+    if (stationId == null) {
+      throw new Error('ChargingStation has no stationInfo.hashId, cannot identify station')
+    }
+    return stationId
   }
 
   private getVariable (
@@ -196,6 +317,8 @@ export class OCPP20VariableManager {
     const { attributeType, component, variable } = variableData
     const requestedAttributeType = attributeType
     const resolvedAttributeType = requestedAttributeType ?? AttributeEnumType.Actual
+    const stationId = this.getStationId(chargingStation)
+    const invalidVariables = this.getInvalidVariables(stationId)
 
     if (!this.isComponentValid(chargingStation, component)) {
       return this.rejectGet(
@@ -253,7 +376,7 @@ export class OCPP20VariableManager {
       component.instance,
       variable.name
     )
-    if (this.invalidVariables.has(variableKey)) {
+    if (invalidVariables.has(variableKey)) {
       return this.rejectGet(
         variable,
         component,
@@ -349,10 +472,10 @@ export class OCPP20VariableManager {
     )
     let valueSize: string | undefined
     let reportingValueSize: string | undefined
-    if (!this.invalidVariables.has(valueSizeKey)) {
+    if (!invalidVariables.has(valueSizeKey)) {
       valueSize = getConfigurationKey(chargingStation, OCPP20RequiredVariableName.ValueSize)?.value
     }
-    if (!this.invalidVariables.has(reportingValueSizeKey)) {
+    if (!invalidVariables.has(reportingValueSizeKey)) {
       reportingValueSize = getConfigurationKey(
         chargingStation,
         OCPP20RequiredVariableName.ReportingValueSize
@@ -516,6 +639,8 @@ export class OCPP20VariableManager {
   ): OCPP20SetVariableResultType {
     const { attributeType, attributeValue, component, variable } = variableData
     const resolvedAttributeType = attributeType ?? AttributeEnumType.Actual
+    const stationId = this.getStationId(chargingStation)
+    const invalidVariables = this.getInvalidVariables(stationId)
 
     if (!this.isComponentValid(chargingStation, component)) {
       return this.rejectSet(
@@ -559,10 +684,7 @@ export class OCPP20VariableManager {
       component.instance,
       variable.name
     )
-    if (
-      this.invalidVariables.has(variableKey) &&
-      resolvedAttributeType === AttributeEnumType.Actual
-    ) {
+    if (invalidVariables.has(variableKey) && resolvedAttributeType === AttributeEnumType.Actual) {
       if (variableMetadata.mutability !== MutabilityEnumType.WriteOnly) {
         return this.rejectSet(
           variable,
@@ -573,7 +695,7 @@ export class OCPP20VariableManager {
           'Variable mapping invalid (startup self-check failed)'
         )
       } else {
-        this.invalidVariables.delete(variableKey)
+        invalidVariables.delete(variableKey)
       }
     }
 
@@ -715,13 +837,13 @@ export class OCPP20VariableManager {
       )
       let configurationValueSizeRaw: string | undefined
       let valueSizeRaw: string | undefined
-      if (!this.invalidVariables.has(configurationValueSizeKey)) {
+      if (!invalidVariables.has(configurationValueSizeKey)) {
         configurationValueSizeRaw = getConfigurationKey(
           chargingStation,
           OCPP20RequiredVariableName.ConfigurationValueSize
         )?.value
       }
-      if (!this.invalidVariables.has(valueSizeKey)) {
+      if (!invalidVariables.has(valueSizeKey)) {
         valueSizeRaw = getConfigurationKey(
           chargingStation,
           OCPP20RequiredVariableName.ValueSize
index 61a4b66f6de5c210b25401693cc36a5cd4c5e55e..941c3c2957b27d552bac8a08440baec46930a95f 100644 (file)
@@ -67,6 +67,7 @@ export enum DeleteCertificateStatusEnumType {
 }
 
 export enum FirmwareStatusEnumType {
+  AcceptedCanceled = 'AcceptedCanceled',
   Downloaded = 'Downloaded',
   DownloadFailed = 'DownloadFailed',
   Downloading = 'Downloading',
@@ -308,9 +309,11 @@ export enum ReasonCodeEnumType {
   FwUpdateInProgress = 'FwUpdateInProgress',
   InternalError = 'InternalError',
   InvalidCertificate = 'InvalidCertificate',
+  InvalidConfSlot = 'InvalidConfSlot',
   InvalidCSR = 'InvalidCSR',
   InvalidIdToken = 'InvalidIdToken',
   InvalidMessageSeq = 'InvalidMessageSeq',
+  InvalidNetworkConf = 'InvalidNetworkConf',
   InvalidProfile = 'InvalidProfile',
   InvalidSchedule = 'InvalidSchedule',
   InvalidStackLevel = 'InvalidStackLevel',
@@ -320,8 +323,10 @@ export enum ReasonCodeEnumType {
   MissingParam = 'MissingParam',
   NoCable = 'NoCable',
   NoError = 'NoError',
+  NoSecurityDowngrade = 'NoSecurityDowngrade',
   NotEnabled = 'NotEnabled',
   NotFound = 'NotFound',
+  NotSupported = 'NotSupported',
   OutOfMemory = 'OutOfMemory',
   OutOfStorage = 'OutOfStorage',
   ReadOnly = 'ReadOnly',
diff --git a/tests/charging-station/ocpp/2.0/Asn1DerUtils.test.ts b/tests/charging-station/ocpp/2.0/Asn1DerUtils.test.ts
new file mode 100644 (file)
index 0000000..8387c1e
--- /dev/null
@@ -0,0 +1,111 @@
+/**
+ * @file Tests for Asn1DerUtils
+ * @description Unit tests for ASN.1 DER encoding primitives and PKCS#10 CSR generation
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, describe, it } from 'node:test'
+
+import {
+  derInteger,
+  derLength,
+  derSequence,
+  generatePkcs10Csr,
+} from '../../../../src/charging-station/ocpp/2.0/Asn1DerUtils.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+
+await describe('ASN.1 DER encoding utilities', async () => {
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('derInteger', async () => {
+    await it('should encode 0 as valid DER INTEGER', () => {
+      const result = derInteger(0)
+      assert.deepStrictEqual(result, Buffer.from([0x02, 0x01, 0x00]))
+    })
+
+    await it('should encode 127 as valid DER INTEGER', () => {
+      const result = derInteger(127)
+      assert.deepStrictEqual(result, Buffer.from([0x02, 0x01, 0x7f]))
+    })
+
+    await it('should throw RangeError for value 128', () => {
+      assert.throws(() => derInteger(128), RangeError)
+    })
+
+    await it('should throw RangeError for negative value', () => {
+      assert.throws(() => derInteger(-1), RangeError)
+    })
+  })
+
+  await describe('derLength', async () => {
+    await it('should encode short form length (<128)', () => {
+      const result = derLength(42)
+      assert.deepStrictEqual(result, Buffer.from([42]))
+    })
+
+    await it('should encode single-byte long form length (128-255)', () => {
+      const result = derLength(200)
+      assert.deepStrictEqual(result, Buffer.from([0x81, 200]))
+    })
+
+    await it('should encode two-byte long form length (>=256)', () => {
+      const result = derLength(300)
+      assert.deepStrictEqual(result, Buffer.from([0x82, 0x01, 0x2c]))
+    })
+
+    await it('should encode boundary value 127 in short form', () => {
+      const result = derLength(127)
+      assert.deepStrictEqual(result, Buffer.from([0x7f]))
+    })
+
+    await it('should encode boundary value 128 in long form', () => {
+      const result = derLength(128)
+      assert.deepStrictEqual(result, Buffer.from([0x81, 0x80]))
+    })
+  })
+
+  await describe('derSequence', async () => {
+    await it('should wrap content with SEQUENCE tag 0x30', () => {
+      const inner = Buffer.from([0x01, 0x02])
+      const result = derSequence(inner)
+      assert.strictEqual(result[0], 0x30)
+      assert.strictEqual(result[1], 2)
+      assert.deepStrictEqual(result.subarray(2), inner)
+    })
+
+    await it('should concatenate multiple items', () => {
+      const a = Buffer.from([0xaa])
+      const b = Buffer.from([0xbb])
+      const result = derSequence(a, b)
+      assert.strictEqual(result[0], 0x30)
+      assert.strictEqual(result[1], 2)
+      assert.strictEqual(result[2], 0xaa)
+      assert.strictEqual(result[3], 0xbb)
+    })
+
+    await it('should handle empty content', () => {
+      const result = derSequence(Buffer.alloc(0))
+      assert.deepStrictEqual(result, Buffer.from([0x30, 0x00]))
+    })
+  })
+
+  await describe('generatePkcs10Csr', async () => {
+    await it('should produce valid PEM-encoded CSR', () => {
+      const csr = generatePkcs10Csr('TestStation', 'TestOrg')
+      assert.match(csr, /^-----BEGIN CERTIFICATE REQUEST-----\n/)
+      assert.match(csr, /\n-----END CERTIFICATE REQUEST-----$/)
+    })
+
+    await it('should contain base64-encoded content with 64-char lines', () => {
+      const csr = generatePkcs10Csr('TestStation', 'TestOrg')
+      const lines = csr.split('\n')
+      const contentLines = lines.slice(1, -1)
+      for (const line of contentLines.slice(0, -1)) {
+        assert.strictEqual(line.length, 64)
+      }
+      assert.ok(contentLines[contentLines.length - 1].length <= 64)
+    })
+  })
+})
index 7ad034065ea220957a3580835c875d639a3dfd5a..bee5d608f2f066457afd26a6b6c706c8536720d9 100644 (file)
@@ -16,22 +16,16 @@ import {
 import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import {
   EMPTY_PEM_CERTIFICATE,
+  EXPIRED_X509_PEM_CERTIFICATE,
   INVALID_PEM_CERTIFICATE_MISSING_MARKERS,
   INVALID_PEM_WRONG_MARKERS,
   VALID_PEM_CERTIFICATE_EXTENDED,
+  VALID_X509_PEM_CERTIFICATE,
 } from './OCPP20CertificateTestData.js'
 
 const TEST_STATION_HASH_ID = 'test-station-hash-12345'
 const TEST_CERT_TYPE = InstallCertificateUseEnumType.CSMSRootCertificate
 
-// eslint-disable-next-line @typescript-eslint/no-unused-vars -- kept for future assertions
-const _EXPECTED_HASH_DATA = {
-  hashAlgorithm: HashAlgorithmEnumType.SHA256,
-  issuerKeyHash: /^[a-fA-F0-9]+$/,
-  issuerNameHash: /^[a-fA-F0-9]+$/,
-  serialNumber: '<any-string>',
-}
-
 await describe('I02-I04 - ISO15118 Certificate Management', async () => {
   afterEach(async () => {
     await rm(`dist/assets/configurations/${TEST_STATION_HASH_ID}`, {
@@ -422,4 +416,35 @@ await describe('I02-I04 - ISO15118 Certificate Management', async () => {
       assert.ok(!path.includes('..'))
     })
   })
+
+  await describe('validateCertificateX509', async () => {
+    let manager: OCPP20CertificateManager
+
+    beforeEach(() => {
+      manager = new OCPP20CertificateManager()
+    })
+
+    await it('should return valid for a real X.509 certificate within validity period', () => {
+      const result = manager.validateCertificateX509(VALID_X509_PEM_CERTIFICATE)
+
+      assert.strictEqual(result.valid, true)
+      assert.strictEqual(result.reason, undefined)
+    })
+
+    await it('should return invalid with reason for an expired X.509 certificate', () => {
+      const result = manager.validateCertificateX509(EXPIRED_X509_PEM_CERTIFICATE)
+
+      assert.strictEqual(result.valid, false)
+      assert.strictEqual(typeof result.reason, 'string')
+      assert.ok(result.reason?.includes('expired'))
+    })
+
+    await it('should return invalid with reason for non-PEM data', () => {
+      const result = manager.validateCertificateX509('not-a-certificate')
+
+      assert.strictEqual(result.valid, false)
+      assert.strictEqual(typeof result.reason, 'string')
+      assert.ok(result.reason?.includes('No PEM certificate found'))
+    })
+  })
 })
index 7ebb92bd80f05cee48735c6a3291a03e8adb5af1..13c2a5400c0e344d331b36bbfdcdc0f1b8cccefe 100644 (file)
@@ -96,3 +96,39 @@ SIb3DQEBCwUAA0EAexpired==
  * Empty PEM certificate for edge case testing.
  */
 export const EMPTY_PEM_CERTIFICATE = ''
+
+// ============================================================================
+// Real X.509 Certificates (parseable by node:crypto X509Certificate)
+// ============================================================================
+
+/**
+ * Valid self-signed X.509 certificate (EC P-256, CN=TestCA, valid 2026-2036).
+ * Parseable by node:crypto X509Certificate for X.509 structural validation tests.
+ */
+export const VALID_X509_PEM_CERTIFICATE = `-----BEGIN CERTIFICATE-----
+MIICBjCCAawCCQDuW/VTwcEHDTAKBggqhkjOPQQDAjARMQ8wDQYDVQQDDAZUZXN0
+Q0EwHhcNMjYwMzE1MTExNjQzWhcNMzYwMzEyMTExNjQzWjARMQ8wDQYDVQQDDAZU
+ZXN0Q0EwggFLMIIBAwYHKoZIzj0CATCB9wIBATAsBgcqhkjOPQEBAiEA/////wAA
+AAEAAAAAAAAAAAAAAAD///////////////8wWwQg/////wAAAAEAAAAAAAAAAAAA
+AAD///////////////wEIFrGNdiqOpPns+u9VXaYhrxlHQawzFOw9jvOPD4n0mBL
+AxUAxJ02CIbnBJNqZnjhE50mt4GffpAEQQRrF9Hy4SxCR/i85uVjpEDydwN9gS3r
+M6D0oTlF2JjClk/jQuL+Gn+bjufrSnwPnhYrzjNXazFezsu2QGg3v1H1AiEA////
+/wAAAAD//////////7zm+q2nF56E87nKwvxjJVECAQEDQgAEwcOlW27L1Aeb6kYl
+Swt1J0moUufzm1+KiBRQQqjIEQiDyuODUfsUk199Ecc/bwXCFnZn9JwdTsdjtGth
+uJXkljAKBggqhkjOPQQDAgNIADBFAiEApXdkBYZyW6gCZF9DofB0SJvxbi7aDybE
+IHCV6XLaqZACIHEFQs2oVgHqMTxx22E7ZffFpA5hNP021SFwJ9ujSYQj
+-----END CERTIFICATE-----`
+
+/**
+ * Expired self-signed X.509 certificate (EC P-256, CN=ExpiredTestCA, valid 2020-2021).
+ * Parseable by node:crypto X509Certificate; triggers expiration check.
+ */
+export const EXPIRED_X509_PEM_CERTIFICATE = `-----BEGIN CERTIFICATE-----
+MIIBLjCB1qADAgECAhRHkkXuRncB5xOMPnTt0pqA/uWOsTAKBggqhkjOPQQDAjAY
+MRYwFAYDVQQDDA1FeHBpcmVkVGVzdENBMB4XDTIwMDEwMTAwMDAwMFoXDTIxMDEw
+MTAwMDAwMFowGDEWMBQGA1UEAwwNRXhwaXJlZFRlc3RDQTBZMBMGByqGSM49AgEG
+CCqGSM49AwEHA0IABKuU0iAUoSQJrTDMZzdNZPLXgCNLmMS6PZkt0Ml8oBIRHTu0
+nR2Dm+OL4pHpa31dWSPRPeAZYUlsz8zER/rARNUwCgYIKoZIzj0EAwIDRwAwRAIg
+XvHyZ5eCRgOTBCMBDXvxxZXGaWFsrhq066F0MKd6D1ICICrQl8LxyYh72Bc0gWBG
+2LQtv+sPK1CmsMqp8G8DiFqY
+-----END CERTIFICATE-----`
index ee7605c0fed66ef0a7578b96974ce6bd5809027c..f2c809640f779415980c83f1ae6e82cc2c6a9f70 100644 (file)
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, it, mock } from 'node:test'
 import type { ChargingStation } from '../../../../src/charging-station/index.js'
 import type { ChargingStationWithCertificateManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20CertificateManager.js'
 
+import { addConfigurationKey } from '../../../../src/charging-station/ConfigurationKeyUtils.js'
 import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
 import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
 import {
@@ -16,6 +17,7 @@ import {
   GenericStatus,
   type OCPP20CertificateSignedRequest,
   type OCPP20CertificateSignedResponse,
+  OCPP20RequestCommand,
   OCPPVersion,
 } from '../../../../src/types/index.js'
 import { Constants } from '../../../../src/utils/index.js'
@@ -29,13 +31,13 @@ import {
 } from './OCPP20CertificateTestData.js'
 import {
   createMockCertificateManager,
+  createMockStationWithRequestTracking,
   createStationWithCertificateManager,
 } from './OCPP20TestUtils.js'
 
 await describe('I04 - CertificateSigned', async () => {
   let station: ChargingStation
   let stationWithCertManager: ChargingStationWithCertificateManager
-  let incomingRequestService: OCPP20IncomingRequestService
   let testableService: ReturnType<typeof createTestableIncomingRequestService>
 
   beforeEach(() => {
@@ -56,8 +58,7 @@ await describe('I04 - CertificateSigned', async () => {
       createMockCertificateManager()
     )
     station.closeWSConnection = mock.fn()
-    incomingRequestService = new OCPP20IncomingRequestService()
-    testableService = createTestableIncomingRequestService(incomingRequestService)
+    testableService = createTestableIncomingRequestService(new OCPP20IncomingRequestService())
   })
 
   afterEach(() => {
@@ -296,4 +297,84 @@ await describe('I04 - CertificateSigned', async () => {
       assert.ok(response.statusInfo.reasonCode.length <= 20)
     })
   })
+
+  await describe('MaxCertificateChainSize Enforcement', async () => {
+    await it('should reject certificate chain exceeding MaxCertificateChainSize', async () => {
+      // Arrange
+      stationWithCertManager.certificateManager = createMockCertificateManager({
+        storeCertificateResult: true,
+      })
+
+      addConfigurationKey(station, 'MaxCertificateChainSize', '10')
+
+      const request: OCPP20CertificateSignedRequest = {
+        certificateChain: VALID_PEM_CERTIFICATE,
+        certificateType: CertificateSigningUseEnumType.ChargingStationCertificate,
+      }
+
+      // Act
+      const response: OCPP20CertificateSignedResponse =
+        await testableService.handleRequestCertificateSigned(station, request)
+
+      // Assert
+      assert.strictEqual(response.status, GenericStatus.Rejected)
+      assert.notStrictEqual(response.statusInfo, undefined)
+      assert.strictEqual(response.statusInfo?.reasonCode, 'InvalidCertificate')
+      assert.ok(response.statusInfo.additionalInfo?.includes('MaxCertificateChainSize'))
+    })
+
+    await it('should accept certificate chain within MaxCertificateChainSize', async () => {
+      // Arrange
+      stationWithCertManager.certificateManager = createMockCertificateManager({
+        storeCertificateResult: true,
+      })
+
+      addConfigurationKey(station, 'MaxCertificateChainSize', '100000')
+
+      const request: OCPP20CertificateSignedRequest = {
+        certificateChain: VALID_PEM_CERTIFICATE,
+        certificateType: CertificateSigningUseEnumType.ChargingStationCertificate,
+      }
+
+      // Act
+      const response: OCPP20CertificateSignedResponse =
+        await testableService.handleRequestCertificateSigned(station, request)
+
+      // Assert
+      assert.strictEqual(response.status, GenericStatus.Accepted)
+    })
+  })
+
+  await describe('SecurityEventNotification on X.509 Failure', async () => {
+    await it('should send SecurityEventNotification when X.509 validation fails', async () => {
+      // Arrange
+      const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking()
+      createStationWithCertificateManager(
+        trackingStation,
+        createMockCertificateManager({
+          validateCertificateX509Result: { reason: 'Certificate expired', valid: false },
+        })
+      )
+
+      const request: OCPP20CertificateSignedRequest = {
+        certificateChain: VALID_PEM_CERTIFICATE,
+        certificateType: CertificateSigningUseEnumType.ChargingStationCertificate,
+      }
+
+      // Act
+      const response: OCPP20CertificateSignedResponse =
+        await testableService.handleRequestCertificateSigned(trackingStation, request)
+
+      // Assert
+      assert.strictEqual(response.status, GenericStatus.Rejected)
+
+      const securityEvents = sentRequests.filter(
+        r =>
+          (r.command as OCPP20RequestCommand) === OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION
+      )
+      assert.strictEqual(securityEvents.length, 1)
+      assert.strictEqual(securityEvents[0].payload.type, 'InvalidChargingStationCertificate')
+      assert.ok((securityEvents[0].payload.techInfo as string).includes('Certificate expired'))
+    })
+  })
 })
index d53ffcb0d293c9396374d8a979fcfcd1e0fab19f..9965b580180a8b9e004484c4193f219f9967512e 100644 (file)
@@ -12,11 +12,13 @@ import { createTestableIncomingRequestService } from '../../../../src/charging-s
 import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
 import {
   CustomerInformationStatusEnumType,
+  HashAlgorithmEnumType,
   type OCPP20CustomerInformationRequest,
   type OCPP20CustomerInformationResponse,
   OCPP20IncomingRequestCommand,
   OCPPVersion,
 } from '../../../../src/types/index.js'
+import { OCPP20IdTokenEnumType } from '../../../../src/types/ocpp/2.0/Transaction.js'
 import { Constants } from '../../../../src/utils/index.js'
 import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
@@ -60,10 +62,11 @@ await describe('N32 - CustomerInformation', async () => {
     assert.strictEqual(response.status, CustomerInformationStatusEnumType.Accepted)
   })
 
-  // TC_N_32_CS: CS must respond to CustomerInformation with Accepted for report requests
-  await it('should respond with Accepted status for report request', () => {
+  // TC_N_32_CS: CS must respond to CustomerInformation with Accepted for report requests with valid identifier
+  await it('should respond with Accepted status for report request with exactly one identifier', () => {
     const response = testableService.handleRequestCustomerInformation(station, {
       clear: false,
+      idToken: { idToken: 'TOKEN_001', type: OCPP20IdTokenEnumType.Central },
       report: true,
       requestId: 2,
     })
@@ -86,6 +89,76 @@ await describe('N32 - CustomerInformation', async () => {
     assert.strictEqual(typeof response, 'object')
     assert.notStrictEqual(response.status, undefined)
     assert.strictEqual(response.status, CustomerInformationStatusEnumType.Rejected)
+    assert.notStrictEqual(response.statusInfo, undefined)
+    assert.strictEqual(typeof response.statusInfo, 'object')
+    assert.notStrictEqual(response.statusInfo?.reasonCode, undefined)
+    assert.notStrictEqual(response.statusInfo?.additionalInfo, undefined)
+  })
+
+  await describe('N09.FR.09 - Customer identifier validation', async () => {
+    await it('should respond with Invalid when report=true and no identifier provided', () => {
+      const response = testableService.handleRequestCustomerInformation(station, {
+        clear: false,
+        report: true,
+        requestId: 10,
+      })
+
+      assert.strictEqual(response.status, CustomerInformationStatusEnumType.Invalid)
+      assert.notStrictEqual(response.statusInfo, undefined)
+      assert.strictEqual(response.statusInfo?.reasonCode, 'InvalidValue')
+      assert.notStrictEqual(response.statusInfo.additionalInfo, undefined)
+    })
+
+    await it('should respond with Invalid when report=true and two identifiers provided', () => {
+      const response = testableService.handleRequestCustomerInformation(station, {
+        clear: false,
+        customerIdentifier: 'CUSTOMER_001',
+        idToken: { idToken: 'TOKEN_001', type: OCPP20IdTokenEnumType.Central },
+        report: true,
+        requestId: 11,
+      })
+
+      assert.strictEqual(response.status, CustomerInformationStatusEnumType.Invalid)
+      assert.notStrictEqual(response.statusInfo, undefined)
+      assert.strictEqual(response.statusInfo?.reasonCode, 'InvalidValue')
+    })
+
+    await it('should respond with Accepted when report=true and exactly one identifier (customerIdentifier)', () => {
+      const response = testableService.handleRequestCustomerInformation(station, {
+        clear: false,
+        customerIdentifier: 'CUSTOMER_001',
+        report: true,
+        requestId: 12,
+      })
+
+      assert.strictEqual(response.status, CustomerInformationStatusEnumType.Accepted)
+    })
+
+    await it('should respond with Accepted when report=true and exactly one identifier (customerCertificate)', () => {
+      const response = testableService.handleRequestCustomerInformation(station, {
+        clear: false,
+        customerCertificate: {
+          hashAlgorithm: HashAlgorithmEnumType.SHA256,
+          issuerKeyHash: 'abc123',
+          issuerNameHash: 'def456',
+          serialNumber: '789',
+        },
+        report: true,
+        requestId: 13,
+      })
+
+      assert.strictEqual(response.status, CustomerInformationStatusEnumType.Accepted)
+    })
+
+    await it('should respond with Accepted when clear=true without identifier', () => {
+      const response = testableService.handleRequestCustomerInformation(station, {
+        clear: true,
+        report: false,
+        requestId: 14,
+      })
+
+      assert.strictEqual(response.status, CustomerInformationStatusEnumType.Accepted)
+    })
   })
 
   await it('should register CUSTOMER_INFORMATION event listener in constructor', () => {
@@ -108,6 +181,7 @@ await describe('N32 - CustomerInformation', async () => {
 
     const request: OCPP20CustomerInformationRequest = {
       clear: false,
+      idToken: { idToken: 'TOKEN_001', type: OCPP20IdTokenEnumType.Central },
       report: true,
       requestId: 20,
     }
@@ -191,6 +265,7 @@ await describe('N32 - CustomerInformation', async () => {
 
     const request: OCPP20CustomerInformationRequest = {
       clear: false,
+      idToken: { idToken: 'TOKEN_001', type: OCPP20IdTokenEnumType.Central },
       report: true,
       requestId: 99,
     }
index 2137f7f21e1ee624675820a22ec328ebd0e538a9..83a8339c48154d730ebafcba92ebea3f6739c775 100644 (file)
@@ -13,6 +13,7 @@ import { createTestableIncomingRequestService } from '../../../../src/charging-s
 import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
 import {
   DeleteCertificateStatusEnumType,
+  GetCertificateIdUseEnumType,
   HashAlgorithmEnumType,
   type OCPP20DeleteCertificateRequest,
   type OCPP20DeleteCertificateResponse,
@@ -24,6 +25,7 @@ import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
 import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
 import {
+  createMockCertificateHashDataChain,
   createMockCertificateManager,
   createStationWithCertificateManager,
 } from './OCPP20TestUtils.js'
@@ -49,7 +51,6 @@ await describe('I04 - DeleteCertificate', async () => {
 
   let station: ChargingStation
   let stationWithCertManager: ChargingStationWithCertificateManager
-  let incomingRequestService: OCPP20IncomingRequestService
   let testableService: ReturnType<typeof createTestableIncomingRequestService>
 
   beforeEach(() => {
@@ -71,8 +72,7 @@ await describe('I04 - DeleteCertificate', async () => {
       createMockCertificateManager()
     )
 
-    incomingRequestService = new OCPP20IncomingRequestService()
-    testableService = createTestableIncomingRequestService(incomingRequestService)
+    testableService = createTestableIncomingRequestService(new OCPP20IncomingRequestService())
   })
 
   await describe('Valid Certificate Deletion', async () => {
@@ -266,4 +266,48 @@ await describe('I04 - DeleteCertificate', async () => {
       assert.ok(response.statusInfo.reasonCode.length <= 20)
     })
   })
+
+  await describe('M04.FR.06 - ChargingStationCertificate Protection', async () => {
+    await it('should reject deletion of ChargingStationCertificate', async () => {
+      const chargingStationCertHash = createMockCertificateHashDataChain(
+        GetCertificateIdUseEnumType.CSMSRootCertificate,
+        'CHARGING_STATION_CERT_SERIAL'
+      )
+
+      stationWithCertManager.certificateManager = createMockCertificateManager({
+        getInstalledCertificatesResult: [chargingStationCertHash],
+      })
+
+      const request: OCPP20DeleteCertificateRequest = {
+        certificateHashData: chargingStationCertHash.certificateHashData,
+      }
+
+      const response: OCPP20DeleteCertificateResponse =
+        await testableService.handleRequestDeleteCertificate(stationWithCertManager, request)
+
+      assert.notStrictEqual(response, undefined)
+      assert.strictEqual(response.status, DeleteCertificateStatusEnumType.Failed)
+      assert.notStrictEqual(response.statusInfo, undefined)
+      assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.NotSupported)
+      assert.ok(response.statusInfo.additionalInfo?.includes('M04.FR.06'))
+    })
+
+    await it('should allow deletion of non-ChargingStationCertificate when no ChargingStationCertificate exists', async () => {
+      stationWithCertManager.certificateManager = createMockCertificateManager({
+        deleteCertificateResult: { status: DeleteCertificateStatusEnumType.Accepted },
+        getInstalledCertificatesResult: [],
+      })
+
+      const request: OCPP20DeleteCertificateRequest = {
+        certificateHashData: VALID_CERTIFICATE_HASH_DATA,
+      }
+
+      const response: OCPP20DeleteCertificateResponse =
+        await testableService.handleRequestDeleteCertificate(stationWithCertManager, request)
+
+      assert.notStrictEqual(response, undefined)
+      assert.strictEqual(response.status, DeleteCertificateStatusEnumType.Accepted)
+      assert.strictEqual(response.statusInfo, undefined)
+    })
+  })
 })
index 5bacfe0950bdf87abed1101d6140a6866624d226..2e02c056dcf554a6bc399333fbfa8a222c8eaebd 100644 (file)
@@ -1,6 +1,7 @@
 /**
  * @file Tests for OCPP20IncomingRequestService GetLog
- * @description Unit tests for OCPP 2.0.1 GetLog command handling (K01)
+ * @description Unit tests for OCPP 2.0.1 GetLog command handling (K01) and
+ *   LogStatusNotification lifecycle simulation per N01
  */
 
 import assert from 'node:assert/strict'
@@ -17,11 +18,17 @@ import {
   type OCPP20GetLogResponse,
   OCPP20IncomingRequestCommand,
   OCPPVersion,
+  UploadLogStatusEnumType,
 } from '../../../../src/types/index.js'
 import { Constants } from '../../../../src/utils/index.js'
-import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import {
+  flushMicrotasks,
+  standardCleanup,
+  withMockTimers,
+} from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
 import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+import { createMockStationWithRequestTracking } from './OCPP20TestUtils.js'
 
 await describe('K01 - GetLog', async () => {
   let station: ChargingStation
@@ -173,4 +180,111 @@ await describe('K01 - GetLog', async () => {
 
     await Promise.resolve()
   })
+
+  await describe('N01 - LogStatusNotification lifecycle', async () => {
+    afterEach(() => {
+      standardCleanup()
+    })
+
+    await it('should send Uploading notification with correct requestId', async t => {
+      await withMockTimers(t, ['setTimeout'], async () => {
+        // Arrange
+        const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking()
+        const service = new OCPP20IncomingRequestService()
+        const request: OCPP20GetLogRequest = {
+          log: { remoteLocation: 'ftp://logs.example.com/' },
+          logType: LogEnumType.DiagnosticsLog,
+          requestId: 42,
+        }
+        const response: OCPP20GetLogResponse = {
+          filename: 'simulator-log.txt',
+          status: LogStatusEnumType.Accepted,
+        }
+
+        // Act
+        service.emit(OCPP20IncomingRequestCommand.GET_LOG, trackingStation, request, response)
+        await flushMicrotasks()
+
+        // Assert
+        assert.ok(sentRequests.length >= 1, 'Expected at least one notification')
+        assert.deepStrictEqual(sentRequests[0].payload, {
+          requestId: 42,
+          status: UploadLogStatusEnumType.Uploading,
+        })
+      })
+    })
+
+    await it('should send Uploaded notification with correct requestId after delay', async t => {
+      await withMockTimers(t, ['setTimeout'], async () => {
+        // Arrange
+        const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking()
+        const service = new OCPP20IncomingRequestService()
+        const request: OCPP20GetLogRequest = {
+          log: { remoteLocation: 'ftp://logs.example.com/' },
+          logType: LogEnumType.DiagnosticsLog,
+          requestId: 42,
+        }
+        const response: OCPP20GetLogResponse = {
+          filename: 'simulator-log.txt',
+          status: LogStatusEnumType.Accepted,
+        }
+
+        // Act
+        service.emit(OCPP20IncomingRequestCommand.GET_LOG, trackingStation, request, response)
+        await flushMicrotasks()
+
+        // Only Uploading should be sent before the timer fires
+        assert.strictEqual(sentRequests.length, 1)
+
+        // Advance past the simulated upload delay
+        t.mock.timers.tick(1000)
+        await flushMicrotasks()
+
+        // Assert
+        assert.strictEqual(sentRequests.length, 2)
+        assert.deepStrictEqual(sentRequests[1].payload, {
+          requestId: 42,
+          status: UploadLogStatusEnumType.Uploaded,
+        })
+      })
+    })
+
+    await it('should send Uploading before Uploaded in correct sequence', async t => {
+      await withMockTimers(t, ['setTimeout'], async () => {
+        // Arrange
+        const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking()
+        const service = new OCPP20IncomingRequestService()
+        const request: OCPP20GetLogRequest = {
+          log: { remoteLocation: 'ftp://logs.example.com/' },
+          logType: LogEnumType.DiagnosticsLog,
+          requestId: 7,
+        }
+        const response: OCPP20GetLogResponse = {
+          filename: 'simulator-log.txt',
+          status: LogStatusEnumType.Accepted,
+        }
+
+        // Act - complete the full lifecycle
+        service.emit(OCPP20IncomingRequestCommand.GET_LOG, trackingStation, request, response)
+        await flushMicrotasks()
+        t.mock.timers.tick(1000)
+        await flushMicrotasks()
+
+        // Assert - verify sequence and requestId propagation
+        assert.strictEqual(sentRequests.length, 2)
+        assert.strictEqual(
+          sentRequests[0].payload.status,
+          UploadLogStatusEnumType.Uploading,
+          'First notification should be Uploading'
+        )
+        assert.strictEqual(
+          sentRequests[1].payload.status,
+          UploadLogStatusEnumType.Uploaded,
+          'Second notification should be Uploaded'
+        )
+        assert.strictEqual(sentRequests[0].payload.requestId, 7)
+        assert.strictEqual(sentRequests[1].payload.requestId, 7)
+      })
+    })
+  })
 })
index 610629e5946d4758b1e659e20f8d1dc12b2389f4..0a537e9841ca9c30d0b539781e84ce54fd2a1293 100644 (file)
@@ -11,6 +11,7 @@ import type { ChargingStation } from '../../../../src/charging-station/index.js'
 import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
 import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
 import {
+  type OCPP20SetNetworkProfileRequest,
   OCPPVersion,
   ReasonCodeEnumType,
   SetNetworkProfileStatusEnumType,
@@ -49,9 +50,9 @@ await describe('B43 - SetNetworkProfile', async () => {
     standardCleanup()
   })
 
-  // TC_B_43_CS: CS must respond to SetNetworkProfile at minimum with Rejected
-  await it('should respond with Rejected status', () => {
-    const response = testableService.handleRequestSetNetworkProfile(station, {
+  await it('should respond with Accepted status for valid request', () => {
+    // Arrange
+    const validPayload = {
       configurationSlot: 1,
       connectionData: {
         messageTimeout: 30,
@@ -61,18 +62,46 @@ await describe('B43 - SetNetworkProfile', async () => {
         ocppVersion: OCPPVersionEnumType.OCPP20,
         securityProfile: 3,
       },
-    })
+    }
+
+    // Act
+    const response = testableService.handleRequestSetNetworkProfile(station, validPayload)
 
+    // Assert
     assert.notStrictEqual(response, undefined)
     assert.strictEqual(typeof response, 'object')
-    assert.notStrictEqual(response.status, undefined)
+    assert.strictEqual(response.status, SetNetworkProfileStatusEnumType.Accepted)
+    assert.strictEqual(response.statusInfo, undefined)
+  })
+
+  // TC_B_43_CS: CS must respond to SetNetworkProfile at minimum with Rejected
+  await it('should respond with Rejected status for invalid configurationSlot', () => {
+    // Arrange
+    const invalidPayload = {
+      configurationSlot: 0,
+      connectionData: {
+        messageTimeout: 30,
+        ocppCsmsUrl: 'wss://example.com/ocpp',
+        ocppInterface: OCPPInterfaceEnumType.Wired0,
+        ocppTransport: OCPPTransportEnumType.JSON,
+        ocppVersion: OCPPVersionEnumType.OCPP20,
+        securityProfile: 3,
+      },
+    }
+
+    // Act
+    const response = testableService.handleRequestSetNetworkProfile(station, invalidPayload)
+
+    // Assert
     assert.strictEqual(response.status, SetNetworkProfileStatusEnumType.Rejected)
+    assert.notStrictEqual(response.statusInfo, undefined)
+    assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.InvalidNetworkConf)
   })
 
-  // TC_B_43_CS: Verify response includes statusInfo with reasonCode
-  await it('should include statusInfo with UnsupportedRequest reasonCode', () => {
-    const response = testableService.handleRequestSetNetworkProfile(station, {
-      configurationSlot: 1,
+  await it('should respond with Rejected status for negative configurationSlot', () => {
+    // Arrange
+    const invalidPayload = {
+      configurationSlot: -1,
       connectionData: {
         messageTimeout: 30,
         ocppCsmsUrl: 'wss://example.com/ocpp',
@@ -81,11 +110,52 @@ await describe('B43 - SetNetworkProfile', async () => {
         ocppVersion: OCPPVersionEnumType.OCPP20,
         securityProfile: 3,
       },
-    })
+    }
+
+    // Act
+    const response = testableService.handleRequestSetNetworkProfile(station, invalidPayload)
+
+    // Assert
+    assert.strictEqual(response.status, SetNetworkProfileStatusEnumType.Rejected)
+    assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.InvalidNetworkConf)
+  })
+
+  await it('should respond with Rejected status for non-integer configurationSlot', () => {
+    // Arrange
+    const invalidPayload = {
+      configurationSlot: 1.5,
+      connectionData: {
+        messageTimeout: 30,
+        ocppCsmsUrl: 'wss://example.com/ocpp',
+        ocppInterface: OCPPInterfaceEnumType.Wired0,
+        ocppTransport: OCPPTransportEnumType.JSON,
+        ocppVersion: OCPPVersionEnumType.OCPP20,
+        securityProfile: 3,
+      },
+    }
+
+    // Act
+    const response = testableService.handleRequestSetNetworkProfile(station, invalidPayload)
+
+    // Assert
+    assert.strictEqual(response.status, SetNetworkProfileStatusEnumType.Rejected)
+    assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.InvalidNetworkConf)
+  })
+
+  // TC_B_43_CS: Verify response includes statusInfo with reasonCode
+  await it('should include statusInfo with InvalidValue reasonCode for invalid configurationSlot', () => {
+    // Arrange
+    const invalidPayload = {
+      configurationSlot: 0,
+    } as unknown as OCPP20SetNetworkProfileRequest
+
+    // Act
+    const response = testableService.handleRequestSetNetworkProfile(station, invalidPayload)
 
+    // Assert
     assert.notStrictEqual(response, undefined)
     assert.strictEqual(response.status, SetNetworkProfileStatusEnumType.Rejected)
     assert.notStrictEqual(response.statusInfo, undefined)
-    assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.UnsupportedRequest)
+    assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.InvalidNetworkConf)
   })
 })
index 24213025f01e55042cae3c6b0d78a563a21a0ee4..6e890b54384de256dc361d136ce087ccbd4b9195 100644 (file)
@@ -1,6 +1,6 @@
 /**
  * @file Tests for OCPP20IncomingRequestService UpdateFirmware
- * @description Unit tests for OCPP 2.0.1 UpdateFirmware command handling (J02)
+ * @description Unit tests for OCPP 2.0.1 UpdateFirmware command handling (L01/L02)
  */
 
 import assert from 'node:assert/strict'
@@ -11,18 +11,31 @@ import type { ChargingStation } from '../../../../src/charging-station/index.js'
 import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
 import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
 import {
+  FirmwareStatusEnumType,
+  type FirmwareType,
   OCPP20IncomingRequestCommand,
+  OCPP20RequestCommand,
   type OCPP20UpdateFirmwareRequest,
   type OCPP20UpdateFirmwareResponse,
   OCPPVersion,
+  ReasonCodeEnumType,
   UpdateFirmwareStatusEnumType,
 } from '../../../../src/types/index.js'
 import { Constants } from '../../../../src/utils/index.js'
-import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import {
+  flushMicrotasks,
+  standardCleanup,
+  withMockTimers,
+} from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
 import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+import {
+  createMockCertificateManager,
+  createMockStationWithRequestTracking,
+  createStationWithCertificateManager,
+} from './OCPP20TestUtils.js'
 
-await describe('J02 - UpdateFirmware', async () => {
+await describe('L01/L02 - UpdateFirmware', async () => {
   let station: ChargingStation
   let testableService: ReturnType<typeof createTestableIncomingRequestService>
 
@@ -74,7 +87,7 @@ await describe('J02 - UpdateFirmware', async () => {
         simulateFirmwareUpdateLifecycle: (
           chargingStation: ChargingStation,
           requestId: number,
-          signature?: string
+          firmware: FirmwareType
         ) => Promise<void>
       },
       'simulateFirmwareUpdateLifecycle',
@@ -97,7 +110,7 @@ await describe('J02 - UpdateFirmware', async () => {
 
     assert.strictEqual(simulateMock.mock.callCount(), 1)
     assert.strictEqual(simulateMock.mock.calls[0].arguments[1], 42)
-    assert.strictEqual(simulateMock.mock.calls[0].arguments[2], 'dGVzdA==')
+    assert.deepStrictEqual(simulateMock.mock.calls[0].arguments[2], request.firmware)
   })
 
   await it('should NOT call simulateFirmwareUpdateLifecycle when UPDATE_FIRMWARE event emitted with Rejected response', () => {
@@ -107,7 +120,7 @@ await describe('J02 - UpdateFirmware', async () => {
         simulateFirmwareUpdateLifecycle: (
           chargingStation: ChargingStation,
           requestId: number,
-          signature?: string
+          firmware: FirmwareType
         ) => Promise<void>
       },
       'simulateFirmwareUpdateLifecycle',
@@ -137,7 +150,7 @@ await describe('J02 - UpdateFirmware', async () => {
         simulateFirmwareUpdateLifecycle: (
           chargingStation: ChargingStation,
           requestId: number,
-          signature?: string
+          firmware: FirmwareType
         ) => Promise<void>
       },
       'simulateFirmwareUpdateLifecycle',
@@ -159,4 +172,425 @@ await describe('J02 - UpdateFirmware', async () => {
 
     await Promise.resolve()
   })
+
+  await describe('Security Features', async () => {
+    await it('should return InvalidCertificate for invalid signing certificate PEM', () => {
+      // Arrange
+      const certManager = createMockCertificateManager()
+      const { station: stationWithCert } = createMockChargingStation({
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
+        connectorsCount: 1,
+        evseConfiguration: { evsesCount: 1 },
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        ocppRequestService: {
+          requestHandler: mock.fn(async () => Promise.resolve({})),
+        },
+        stationInfo: {
+          ocppStrictCompliance: false,
+          ocppVersion: OCPPVersion.VERSION_201,
+        },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+      createStationWithCertificateManager(stationWithCert, certManager)
+
+      const request: OCPP20UpdateFirmwareRequest = {
+        firmware: {
+          location: 'https://firmware.example.com/update.bin',
+          retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'),
+          signingCertificate: 'INVALID-NOT-PEM',
+        },
+        requestId: 10,
+      }
+
+      // Act
+      const response = testableService.handleRequestUpdateFirmware(stationWithCert, request)
+
+      // Assert
+      assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.InvalidCertificate)
+    })
+
+    await it('should return Accepted for valid signing certificate PEM', () => {
+      // Arrange
+      const certManager = createMockCertificateManager()
+      const { station: stationWithCert } = createMockChargingStation({
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
+        connectorsCount: 1,
+        evseConfiguration: { evsesCount: 1 },
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: {
+          ocppStrictCompliance: false,
+          ocppVersion: OCPPVersion.VERSION_201,
+        },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+      createStationWithCertificateManager(stationWithCert, certManager)
+
+      const request: OCPP20UpdateFirmwareRequest = {
+        firmware: {
+          location: 'https://firmware.example.com/update.bin',
+          retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'),
+          signingCertificate:
+            '-----BEGIN CERTIFICATE-----\nMIIBkTCB0123...\n-----END CERTIFICATE-----',
+        },
+        requestId: 11,
+      }
+
+      // Act
+      const response = testableService.handleRequestUpdateFirmware(stationWithCert, request)
+
+      // Assert
+      assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted)
+    })
+
+    await it('should return InvalidCertificate when no certificate manager is available', () => {
+      const request: OCPP20UpdateFirmwareRequest = {
+        firmware: {
+          location: 'https://firmware.example.com/update.bin',
+          retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'),
+          signingCertificate: '-----BEGIN CERTIFICATE-----\ndata\n-----END CERTIFICATE-----',
+        },
+        requestId: 12,
+      }
+
+      const response = testableService.handleRequestUpdateFirmware(station, request)
+
+      assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.InvalidCertificate)
+    })
+
+    await it('should return Rejected with TxInProgress when EVSE has active transactions', () => {
+      // Arrange
+      const { station: evseStation } = createMockChargingStation({
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
+        connectorsCount: 2,
+        evseConfiguration: { evsesCount: 2 },
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: {
+          ocppStrictCompliance: false,
+          ocppVersion: OCPPVersion.VERSION_201,
+        },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      // Set an active transaction on EVSE 1's connector
+      const evse1 = evseStation.evses.get(1)
+      if (evse1 != null) {
+        const firstConnector = evse1.connectors.values().next().value
+        if (firstConnector != null) {
+          firstConnector.transactionId = 'tx-active-001'
+        }
+      }
+
+      const request: OCPP20UpdateFirmwareRequest = {
+        firmware: {
+          location: 'https://firmware.example.com/update.bin',
+          retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'),
+        },
+        requestId: 20,
+      }
+
+      // Act
+      const response = testableService.handleRequestUpdateFirmware(evseStation, request)
+
+      // Assert
+      assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Rejected)
+      assert.notStrictEqual(response.statusInfo, undefined)
+      assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.TxInProgress)
+    })
+
+    await it('should return Accepted when no EVSE has active transactions', () => {
+      // Arrange
+      const { station: evseStation } = createMockChargingStation({
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
+        connectorsCount: 2,
+        evseConfiguration: { evsesCount: 2 },
+        heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+        stationInfo: {
+          ocppStrictCompliance: false,
+          ocppVersion: OCPPVersion.VERSION_201,
+        },
+        websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+      })
+
+      const request: OCPP20UpdateFirmwareRequest = {
+        firmware: {
+          location: 'https://firmware.example.com/update.bin',
+          retrieveDateTime: new Date('2025-01-15T10:00:00.000Z'),
+        },
+        requestId: 21,
+      }
+
+      // Act
+      const response = testableService.handleRequestUpdateFirmware(evseStation, request)
+
+      // Assert
+      assert.strictEqual(response.status, UpdateFirmwareStatusEnumType.Accepted)
+    })
+
+    await it('should cancel previous firmware update when new one arrives', async t => {
+      const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking()
+      const service = new OCPP20IncomingRequestService()
+      const testable = createTestableIncomingRequestService(service)
+
+      const firstRequest: OCPP20UpdateFirmwareRequest = {
+        firmware: {
+          location: 'https://firmware.example.com/v1.bin',
+          retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'),
+        },
+        requestId: 100,
+      }
+      const firstResponse: OCPP20UpdateFirmwareResponse = {
+        status: UpdateFirmwareStatusEnumType.Accepted,
+      }
+
+      await withMockTimers(t, ['setTimeout'], async () => {
+        service.emit(
+          OCPP20IncomingRequestCommand.UPDATE_FIRMWARE,
+          trackingStation,
+          firstRequest,
+          firstResponse
+        )
+
+        await flushMicrotasks()
+        assert.strictEqual(sentRequests.length, 1)
+        assert.strictEqual(sentRequests[0].payload.status, FirmwareStatusEnumType.Downloading)
+
+        const secondRequest: OCPP20UpdateFirmwareRequest = {
+          firmware: {
+            location: 'https://firmware.example.com/v2.bin',
+            retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'),
+          },
+          requestId: 101,
+        }
+
+        const secondResponse = testable.handleRequestUpdateFirmware(trackingStation, secondRequest)
+        assert.strictEqual(secondResponse.status, UpdateFirmwareStatusEnumType.Accepted)
+
+        await flushMicrotasks()
+
+        const cancelNotification = sentRequests.find(
+          r =>
+            (r.command as OCPP20RequestCommand) ===
+              OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION &&
+            r.payload.requestId === 100 &&
+            (r.payload.status as FirmwareStatusEnumType) === FirmwareStatusEnumType.AcceptedCanceled
+        )
+        assert.notStrictEqual(cancelNotification, undefined)
+      })
+    })
+  })
+
+  await describe('Firmware Update Lifecycle', async () => {
+    await it('should send full lifecycle Downloading→Downloaded→Installing→Installed + SecurityEvent', async t => {
+      const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking()
+      const service = new OCPP20IncomingRequestService()
+
+      const request: OCPP20UpdateFirmwareRequest = {
+        firmware: {
+          location: 'https://firmware.example.com/update.bin',
+          retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'),
+        },
+        requestId: 1,
+      }
+      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].command,
+          OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION
+        )
+        assert.strictEqual(sentRequests[0].payload.status, FirmwareStatusEnumType.Downloading)
+        assert.strictEqual(sentRequests[0].payload.requestId, 1)
+
+        t.mock.timers.tick(2000)
+        await flushMicrotasks()
+        assert.strictEqual(sentRequests.length, 3)
+        assert.strictEqual(sentRequests[1].payload.status, FirmwareStatusEnumType.Downloaded)
+        assert.strictEqual(sentRequests[2].payload.status, FirmwareStatusEnumType.Installing)
+
+        t.mock.timers.tick(1000)
+        await flushMicrotasks()
+        assert.strictEqual(sentRequests[3].payload.status, FirmwareStatusEnumType.Installed)
+
+        // H11: SecurityEventNotification for FirmwareUpdated
+        assert.strictEqual(sentRequests.length, 5)
+        assert.strictEqual(
+          sentRequests[4].command,
+          OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION
+        )
+        assert.strictEqual(sentRequests[4].payload.type, 'FirmwareUpdated')
+      })
+    })
+
+    await it('should send DownloadFailed for empty firmware location', async t => {
+      const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking()
+      const service = new OCPP20IncomingRequestService()
+
+      const request: OCPP20UpdateFirmwareRequest = {
+        firmware: {
+          location: '',
+          retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'),
+        },
+        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)
+        assert.strictEqual(sentRequests[0].payload.status, FirmwareStatusEnumType.Downloading)
+
+        t.mock.timers.tick(2000)
+        await flushMicrotasks()
+        assert.strictEqual(sentRequests.length, 2)
+        assert.strictEqual(sentRequests[1].payload.status, FirmwareStatusEnumType.DownloadFailed)
+        assert.strictEqual(sentRequests[1].payload.requestId, 7)
+      })
+    })
+
+    await it('should send DownloadFailed for malformed firmware location', async t => {
+      const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking()
+      const service = new OCPP20IncomingRequestService()
+
+      const request: OCPP20UpdateFirmwareRequest = {
+        firmware: {
+          location: 'not-a-valid-url',
+          retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'),
+        },
+        requestId: 8,
+      }
+      const response: OCPP20UpdateFirmwareResponse = {
+        status: UpdateFirmwareStatusEnumType.Accepted,
+      }
+
+      await withMockTimers(t, ['setTimeout'], async () => {
+        service.emit(
+          OCPP20IncomingRequestCommand.UPDATE_FIRMWARE,
+          trackingStation,
+          request,
+          response
+        )
+
+        await flushMicrotasks()
+        t.mock.timers.tick(2000)
+        await flushMicrotasks()
+
+        assert.strictEqual(sentRequests.length, 2)
+        assert.strictEqual(sentRequests[1].payload.status, FirmwareStatusEnumType.DownloadFailed)
+        assert.strictEqual(sentRequests[1].payload.requestId, 8)
+      })
+    })
+
+    await it('should include requestId in all firmware status notifications', async t => {
+      const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking()
+      const service = new OCPP20IncomingRequestService()
+      const expectedRequestId = 42
+
+      const request: OCPP20UpdateFirmwareRequest = {
+        firmware: {
+          location: 'https://firmware.example.com/update.bin',
+          retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'),
+        },
+        requestId: expectedRequestId,
+      }
+      const response: OCPP20UpdateFirmwareResponse = {
+        status: UpdateFirmwareStatusEnumType.Accepted,
+      }
+
+      await withMockTimers(t, ['setTimeout'], async () => {
+        service.emit(
+          OCPP20IncomingRequestCommand.UPDATE_FIRMWARE,
+          trackingStation,
+          request,
+          response
+        )
+
+        await flushMicrotasks()
+        t.mock.timers.tick(2000)
+        await flushMicrotasks()
+        t.mock.timers.tick(1000)
+        await flushMicrotasks()
+
+        const firmwareNotifications = sentRequests.filter(
+          r =>
+            (r.command as OCPP20RequestCommand) ===
+            OCPP20RequestCommand.FIRMWARE_STATUS_NOTIFICATION
+        )
+        assert.strictEqual(firmwareNotifications.length, 4)
+        for (const req of firmwareNotifications) {
+          assert.strictEqual(req.payload.requestId, expectedRequestId)
+        }
+      })
+    })
+
+    await it('should include SignatureVerified when firmware has signature', async t => {
+      const { sentRequests, station: trackingStation } = createMockStationWithRequestTracking()
+      const service = new OCPP20IncomingRequestService()
+
+      const request: OCPP20UpdateFirmwareRequest = {
+        firmware: {
+          location: 'https://firmware.example.com/update.bin',
+          retrieveDateTime: new Date('2020-01-01T00:00:00.000Z'),
+          signature: 'dGVzdA==',
+        },
+        requestId: 5,
+      }
+      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, FirmwareStatusEnumType.Downloaded)
+
+        t.mock.timers.tick(500)
+        await flushMicrotasks()
+        assert.strictEqual(sentRequests[2].payload.status, FirmwareStatusEnumType.SignatureVerified)
+        assert.strictEqual(sentRequests[3].payload.status, FirmwareStatusEnumType.Installing)
+
+        t.mock.timers.tick(1000)
+        await flushMicrotasks()
+        assert.strictEqual(sentRequests[4].payload.status, FirmwareStatusEnumType.Installed)
+
+        // H11: SecurityEventNotification after Installed
+        assert.strictEqual(sentRequests.length, 6)
+        assert.strictEqual(
+          sentRequests[5].command,
+          OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION
+        )
+        assert.strictEqual(sentRequests[5].payload.type, 'FirmwareUpdated')
+      })
+    })
+  })
 })
index 10d6eaad2c33e9cdd4089ca76c7944f08914cb02..f64490e1ca867a30ad3f735b53e9407ba11077b5 100644 (file)
@@ -74,11 +74,11 @@ await describe('I02 - SignCertificate Request', async () => {
 
       const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20SignCertificateRequest
       assert.notStrictEqual(sentPayload.csr, undefined)
-      assert.ok(sentPayload.csr.includes('-----BEGIN CERTIFICATE REQUEST-----'))
-      assert.ok(sentPayload.csr.includes('-----END CERTIFICATE REQUEST-----'))
+      assert.ok(sentPayload.csr.startsWith('-----BEGIN CERTIFICATE REQUEST-----'))
+      assert.ok(sentPayload.csr.endsWith('-----END CERTIFICATE REQUEST-----'))
     })
 
-    await it('should include OrganizationName from SecurityCtrlr config in CSR', async () => {
+    await it('should generate CSR starting with BEGIN CERTIFICATE REQUEST marker', async () => {
       const { sendMessageMock, service } =
         createTestableRequestService<OCPP20SignCertificateResponse>({
           sendMessageResponse: {
@@ -92,16 +92,113 @@ await describe('I02 - SignCertificate Request', async () => {
       )
 
       const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20SignCertificateRequest
-      assert.notStrictEqual(sentPayload.csr, undefined)
-      assert.ok(sentPayload.csr.includes('-----BEGIN CERTIFICATE REQUEST-----'))
+      assert.ok(sentPayload.csr.startsWith('-----BEGIN CERTIFICATE REQUEST-----\n'))
+    })
+
+    await it('should generate CSR ending with END CERTIFICATE REQUEST marker', async () => {
+      const { sendMessageMock, service } =
+        createTestableRequestService<OCPP20SignCertificateResponse>({
+          sendMessageResponse: {
+            status: GenericStatus.Accepted,
+          },
+        })
+
+      await service.requestSignCertificate(
+        station,
+        CertificateSigningUseEnumType.ChargingStationCertificate
+      )
+
+      const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20SignCertificateRequest
+      assert.ok(sentPayload.csr.endsWith('\n-----END CERTIFICATE REQUEST-----'))
+    })
+
+    await it('should generate CSR body with valid Base64 encoding', async () => {
+      const { sendMessageMock, service } =
+        createTestableRequestService<OCPP20SignCertificateResponse>({
+          sendMessageResponse: {
+            status: GenericStatus.Accepted,
+          },
+        })
+
+      await service.requestSignCertificate(
+        station,
+        CertificateSigningUseEnumType.ChargingStationCertificate
+      )
 
-      const csrRegex =
-        /-----BEGIN CERTIFICATE REQUEST-----\n(.+?)\n-----END CERTIFICATE REQUEST-----/
-      const csrExecResult = csrRegex.exec(sentPayload.csr)
-      assert.notStrictEqual(csrExecResult, undefined)
-      const csrData = csrExecResult?.[1]
-      const decodedCsr = Buffer.from(csrData ?? '', 'base64').toString('utf-8')
-      assert.ok(decodedCsr.includes('O=Test Organization Inc.'))
+      const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20SignCertificateRequest
+      const csrLines = sentPayload.csr.split('\n')
+      const base64Body = csrLines.slice(1, -1).join('')
+      assert.ok(/^[A-Za-z0-9+/]+=*$/.test(base64Body), 'CSR body must be valid Base64')
+      const decoded = Buffer.from(base64Body, 'base64')
+      assert.ok(decoded.length > 0, 'Decoded CSR must not be empty')
+    })
+
+    await it('should include station ID in CSR subject DN', async () => {
+      const { sendMessageMock, service } =
+        createTestableRequestService<OCPP20SignCertificateResponse>({
+          sendMessageResponse: {
+            status: GenericStatus.Accepted,
+          },
+        })
+
+      await service.requestSignCertificate(
+        station,
+        CertificateSigningUseEnumType.ChargingStationCertificate
+      )
+
+      const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20SignCertificateRequest
+      const csrLines = sentPayload.csr.split('\n')
+      const base64Body = csrLines.slice(1, -1).join('')
+      const derBytes = Buffer.from(base64Body, 'base64')
+      const stationId = station.stationInfo?.chargingStationId ?? ''
+      assert.ok(stationId.length > 0, 'Station ID must not be empty')
+      assert.ok(
+        derBytes.includes(Buffer.from(stationId, 'utf-8')),
+        'CSR DER must contain station ID'
+      )
+    })
+
+    await it('should include OrganizationName in CSR subject DN', async () => {
+      const { sendMessageMock, service } =
+        createTestableRequestService<OCPP20SignCertificateResponse>({
+          sendMessageResponse: {
+            status: GenericStatus.Accepted,
+          },
+        })
+
+      await service.requestSignCertificate(
+        station,
+        CertificateSigningUseEnumType.ChargingStationCertificate
+      )
+
+      const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20SignCertificateRequest
+      const csrLines = sentPayload.csr.split('\n')
+      const base64Body = csrLines.slice(1, -1).join('')
+      const derBytes = Buffer.from(base64Body, 'base64')
+      assert.ok(
+        derBytes.includes(Buffer.from(MOCK_ORGANIZATION_NAME, 'utf-8')),
+        'CSR DER must contain organization name'
+      )
+    })
+
+    await it('should generate valid ASN.1 DER structure starting with SEQUENCE tag', async () => {
+      const { sendMessageMock, service } =
+        createTestableRequestService<OCPP20SignCertificateResponse>({
+          sendMessageResponse: {
+            status: GenericStatus.Accepted,
+          },
+        })
+
+      await service.requestSignCertificate(
+        station,
+        CertificateSigningUseEnumType.ChargingStationCertificate
+      )
+
+      const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20SignCertificateRequest
+      const csrLines = sentPayload.csr.split('\n')
+      const base64Body = csrLines.slice(1, -1).join('')
+      const derBytes = Buffer.from(base64Body, 'base64')
+      assert.strictEqual(derBytes[0], 0x30, 'CSR DER must start with SEQUENCE tag (0x30)')
     })
   })
 
index a92bc75bd762bb32621e6cd7710a701135d18687..15de7686894b80a4624b4d2325e1d1297ed99e26 100644 (file)
@@ -749,6 +749,8 @@ export interface MockCertificateManagerOptions {
   storeCertificateError?: Error
   /** Result to return from storeCertificate (default: { success: true }) */
   storeCertificateResult?: boolean
+  /** Result to return from validateCertificateX509 (default: { valid: true }) */
+  validateCertificateX509Result?: { reason?: string; valid: boolean }
 }
 
 // ============================================================================
@@ -844,6 +846,9 @@ export function createMockCertificateManager (options: MockCertificateManagerOpt
         cert.includes('-----BEGIN CERTIFICATE-----') && cert.includes('-----END CERTIFICATE-----')
       )
     }),
+    validateCertificateX509: mock.fn(() => {
+      return options.validateCertificateX509Result ?? { valid: true }
+    }),
   }
 }
 
index 199b7674938aa2645326bfa52c28bb14984f56a1..432b424fee7b136036c94227c3839261df9c930d 100644 (file)
@@ -102,6 +102,7 @@ await describe('B05 - OCPP20VariableManager', async () => {
   afterEach(() => {
     standardCleanup()
     OCPP20VariableManager.getInstance().resetRuntimeOverrides()
+    OCPP20VariableManager.getInstance().invalidateMappingsCache()
   })
 
   await it('should return same instance when getInstance() called multiple times', () => {
@@ -2048,4 +2049,158 @@ await describe('B05 - OCPP20VariableManager', async () => {
       assert.strictEqual(cfgAfter?.value, '7')
     })
   })
+
+  await it('should cache validatePersistentMappings and not re-run on second getVariables call', t => {
+    // Arrange
+    const manager = OCPP20VariableManager.getInstance()
+    const validateSpy = t.mock.method(manager, 'validatePersistentMappings')
+    const request: OCPP20GetVariableDataType[] = [
+      {
+        component: { name: OCPP20ComponentName.OCPPCommCtrlr },
+        variable: { name: OCPP20OptionalVariableName.HeartbeatInterval },
+      },
+    ]
+
+    // Act
+    const result1 = manager.getVariables(station, request)
+    const result2 = manager.getVariables(station, request)
+
+    // Assert
+    assert.strictEqual(result1[0].attributeStatus, GetVariableStatusEnumType.Accepted)
+    assert.strictEqual(result2[0].attributeStatus, GetVariableStatusEnumType.Accepted)
+    assert.strictEqual(result1[0].attributeValue, result2[0].attributeValue)
+    assert.strictEqual(
+      validateSpy.mock.callCount(),
+      2,
+      'validatePersistentMappings should be called twice (once per getVariables)'
+    )
+    // The second call should be a no-op (early return) because the station is already validated.
+    // Verify by invalidating and calling again — the third call should re-validate.
+    manager.invalidateMappingsCache()
+    const result3 = manager.getVariables(station, request)
+    assert.strictEqual(result3[0].attributeStatus, GetVariableStatusEnumType.Accepted)
+    assert.strictEqual(result3[0].attributeValue, result1[0].attributeValue)
+    assert.strictEqual(validateSpy.mock.callCount(), 3)
+  })
+
+  await describe('MinSet/MaxSet atomicity tests', async () => {
+    let manager: OCPP20VariableManager
+    const registryKey = `${OCPP20ComponentName.TxCtrlr as string}::${OCPP20RequiredVariableName.EVConnectionTimeOut as string}`
+    let originalSupportedAttributes: AttributeEnumType[]
+
+    beforeEach(() => {
+      manager = OCPP20VariableManager.getInstance()
+      originalSupportedAttributes = [...VARIABLE_REGISTRY[registryKey].supportedAttributes]
+      VARIABLE_REGISTRY[registryKey].supportedAttributes = [
+        AttributeEnumType.Actual,
+        AttributeEnumType.MinSet,
+        AttributeEnumType.MaxSet,
+      ]
+    })
+
+    afterEach(() => {
+      VARIABLE_REGISTRY[registryKey].supportedAttributes = originalSupportedAttributes
+    })
+
+    await it('should accept paired MinSet and MaxSet in a single request when newMin <= newMax', () => {
+      // Arrange: establish a low MaxSet override that would cause individual MinSet=20 to fail
+      const setupResult = manager.setVariables(station, [
+        {
+          attributeType: AttributeEnumType.MaxSet,
+          attributeValue: '10',
+          component: { name: OCPP20ComponentName.TxCtrlr },
+          variable: { name: OCPP20RequiredVariableName.EVConnectionTimeOut },
+        },
+      ])
+      assert.strictEqual(setupResult[0].attributeStatus, SetVariableStatusEnumType.Accepted)
+
+      // Act: send paired MinSet=20 + MaxSet=30 in a single request
+      const results = manager.setVariables(station, [
+        {
+          attributeType: AttributeEnumType.MinSet,
+          attributeValue: '20',
+          component: { name: OCPP20ComponentName.TxCtrlr },
+          variable: { name: OCPP20RequiredVariableName.EVConnectionTimeOut },
+        },
+        {
+          attributeType: AttributeEnumType.MaxSet,
+          attributeValue: '30',
+          component: { name: OCPP20ComponentName.TxCtrlr },
+          variable: { name: OCPP20RequiredVariableName.EVConnectionTimeOut },
+        },
+      ])
+
+      // Assert: both attributes accepted as a coherent pair
+      assert.strictEqual(results.length, 2)
+      assert.strictEqual(results[0].attributeStatus, SetVariableStatusEnumType.Accepted)
+      assert.strictEqual(results[0].attributeType, AttributeEnumType.MinSet)
+      assert.strictEqual(results[1].attributeStatus, SetVariableStatusEnumType.Accepted)
+      assert.strictEqual(results[1].attributeType, AttributeEnumType.MaxSet)
+    })
+  })
+
+  await it('should maintain independent invalidVariables per station', () => {
+    const manager = OCPP20VariableManager.getInstance()
+    const registryKey = `${OCPP20ComponentName.TxCtrlr as string}::${OCPP20RequiredVariableName.EVConnectionTimeOut as string}`
+    const originalDefault = VARIABLE_REGISTRY[registryKey].defaultValue
+
+    const { station: stationA } = createMockChargingStation({
+      baseName: 'StationA',
+      connectorsCount: 3,
+      evseConfiguration: { evsesCount: 3 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        hashId: 'station-a-hash',
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+    const { station: stationB } = createMockChargingStation({
+      baseName: 'StationB',
+      connectorsCount: 3,
+      evseConfiguration: { evsesCount: 3 },
+      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+      stationInfo: {
+        hashId: 'station-b-hash',
+        ocppVersion: OCPPVersion.VERSION_201,
+      },
+      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+    })
+
+    try {
+      deleteConfigurationKey(
+        stationA,
+        OCPP20RequiredVariableName.EVConnectionTimeOut as unknown as VariableType['name'],
+        { save: false }
+      )
+      VARIABLE_REGISTRY[registryKey].defaultValue = undefined
+
+      manager.validatePersistentMappings(stationA)
+
+      VARIABLE_REGISTRY[registryKey].defaultValue = originalDefault
+
+      manager.validatePersistentMappings(stationB)
+
+      const resultA = manager.getVariables(stationA, [
+        {
+          component: { name: OCPP20ComponentName.TxCtrlr },
+          variable: { name: OCPP20RequiredVariableName.EVConnectionTimeOut },
+        },
+      ])[0]
+      assert.strictEqual(resultA.attributeStatus, GetVariableStatusEnumType.Rejected)
+      assert.strictEqual(resultA.attributeStatusInfo?.reasonCode, ReasonCodeEnumType.InternalError)
+
+      const resultB = manager.getVariables(stationB, [
+        {
+          component: { name: OCPP20ComponentName.TxCtrlr },
+          variable: { name: OCPP20RequiredVariableName.EVConnectionTimeOut },
+        },
+      ])[0]
+      assert.strictEqual(resultB.attributeStatus, GetVariableStatusEnumType.Accepted)
+      assert.strictEqual(resultB.attributeValue, Constants.DEFAULT_EV_CONNECTION_TIMEOUT.toString())
+    } finally {
+      VARIABLE_REGISTRY[registryKey].defaultValue = originalDefault
+      manager.invalidateMappingsCache()
+    }
+  })
 })
index 0721a629d930eafe7d9e074b1833a2c937ebc1f1..883c063969e8ac754e591f45deadb6c188ab0bbe 100644 (file)
@@ -307,6 +307,17 @@ export function standardCleanup (): void {
   MockIdTagsCache.resetInstance()
 }
 
+/**
+ * Flush all pending microtasks by yielding to the event loop.
+ * setImmediate fires after all microtasks in the current event loop iteration are drained.
+ * Use this in tests that need to await async side effects triggered by synchronous calls
+ * (e.g. event emitters that fire async handlers).
+ */
+export const flushMicrotasks = (): Promise<void> =>
+  new Promise<void>(resolve => {
+    setImmediate(resolve)
+  })
+
 /**
  * Suspends execution for the specified number of milliseconds.
  * @param ms - Duration to sleep in milliseconds.