From 3a6e89f6347a4b7771dfd2323b43ff89a2711325 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sun, 15 Mar 2026 16:10:29 +0100 Subject: [PATCH] fix(ocpp20): remediate all OCPP 2.0.1 audit findings (#1726) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * 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> --- src/charging-station/ocpp/2.0/Asn1DerUtils.ts | 153 ++++++ .../ocpp/2.0/OCPP20CertificateManager.ts | 57 ++- .../ocpp/2.0/OCPP20IncomingRequestService.ts | 442 +++++++++++++++-- .../ocpp/2.0/OCPP20RequestService.ts | 44 +- .../ocpp/2.0/OCPP20VariableManager.ts | 154 +++++- src/types/ocpp/2.0/Common.ts | 5 + .../ocpp/2.0/Asn1DerUtils.test.ts | 111 +++++ .../ocpp/2.0/OCPP20CertificateManager.test.ts | 41 +- .../ocpp/2.0/OCPP20CertificateTestData.ts | 36 ++ ...ngRequestService-CertificateSigned.test.ts | 87 +++- ...RequestService-CustomerInformation.test.ts | 79 ++- ...ngRequestService-DeleteCertificate.test.ts | 50 +- ...CPP20IncomingRequestService-GetLog.test.ts | 118 ++++- ...ngRequestService-SetNetworkProfile.test.ts | 92 +++- ...omingRequestService-UpdateFirmware.test.ts | 448 +++++++++++++++++- ...PP20RequestService-SignCertificate.test.ts | 121 ++++- .../ocpp/2.0/OCPP20TestUtils.ts | 5 + .../ocpp/2.0/OCPP20VariableManager.test.ts | 155 ++++++ tests/helpers/TestLifecycleHelpers.ts | 11 + 19 files changed, 2079 insertions(+), 130 deletions(-) create mode 100644 src/charging-station/ocpp/2.0/Asn1DerUtils.ts create mode 100644 tests/charging-station/ocpp/2.0/Asn1DerUtils.test.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 index 00000000..0cbabb8f --- /dev/null +++ b/src/charging-station/ocpp/2.0/Asn1DerUtils.ts @@ -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-----` +} diff --git a/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts b/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts index e4db4f3f..3674a85d 100644 --- a/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts +++ b/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts @@ -48,7 +48,8 @@ export interface OCPP20CertificateManagerInterface { ): DeleteCertificateResult | Promise getInstalledCertificates( stationHashId: string, - filterTypes?: InstallCertificateUseEnumType[] + filterTypes?: InstallCertificateUseEnumType[], + hashAlgorithm?: HashAlgorithmEnumType ): GetInstalledCertificatesResult | Promise storeCertificate( stationHashId: string, @@ -56,6 +57,7 @@ export interface OCPP20CertificateManagerInterface { pemData: string ): Promise | 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 { 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. diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index 6d179205..c41ee905 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -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> + 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 { - 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 { + 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 { + 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()}` ) diff --git a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts index 5be8edcf..aeb14a0e 100644 --- a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts @@ -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( diff --git a/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts b/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts index b1133868..0d61f912 100644 --- a/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts +++ b/src/charging-station/ocpp/2.0/OCPP20VariableManager.ts @@ -59,10 +59,11 @@ export class OCPP20VariableManager { Object.keys(VARIABLE_REGISTRY).map(k => k.split('::')[0]) ) - private readonly invalidVariables = new Set() // composite key (lower case) - private readonly maxSetOverrides = new Map() // composite key (lower case) - private readonly minSetOverrides = new Map() // composite key (lower case) - private readonly runtimeOverrides = new Map() // composite key (lower case) + private readonly invalidVariables = new Map>() // stationId → composite keys (lower case) + private readonly maxSetOverrides = new Map() // composite key (lower case) → value + private readonly minSetOverrides = new Map() // composite key (lower case) → value + private readonly runtimeOverrides = new Map() // composite key (lower case) → value + private readonly validatedStations = new Set() // 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() + 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 { + let set = this.invalidVariables.get(stationId) + if (set == null) { + set = new Set() + 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 diff --git a/src/types/ocpp/2.0/Common.ts b/src/types/ocpp/2.0/Common.ts index 61a4b66f..941c3c29 100644 --- a/src/types/ocpp/2.0/Common.ts +++ b/src/types/ocpp/2.0/Common.ts @@ -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 index 00000000..8387c1e5 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/Asn1DerUtils.test.ts @@ -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) + }) + }) +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts index 7ad03406..bee5d608 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts @@ -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: '', -} - 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')) + }) + }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20CertificateTestData.ts b/tests/charging-station/ocpp/2.0/OCPP20CertificateTestData.ts index 7ebb92bd..13c2a540 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20CertificateTestData.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20CertificateTestData.ts @@ -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-----` diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts index ee7605c0..f2c80964 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts @@ -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 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')) + }) + }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts index d53ffcb0..9965b580 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CustomerInformation.test.ts @@ -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, } diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts index 2137f7f2..83a8339c 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts @@ -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 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) + }) + }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts index 5bacfe09..2e02c056 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetLog.test.ts @@ -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) + }) + }) + }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-SetNetworkProfile.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-SetNetworkProfile.test.ts index 610629e5..0a537e98 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-SetNetworkProfile.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-SetNetworkProfile.test.ts @@ -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) }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts index 24213025..6e890b54 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-UpdateFirmware.test.ts @@ -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 @@ -74,7 +87,7 @@ await describe('J02 - UpdateFirmware', async () => { simulateFirmwareUpdateLifecycle: ( chargingStation: ChargingStation, requestId: number, - signature?: string + firmware: FirmwareType ) => Promise }, '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 }, 'simulateFirmwareUpdateLifecycle', @@ -137,7 +150,7 @@ await describe('J02 - UpdateFirmware', async () => { simulateFirmwareUpdateLifecycle: ( chargingStation: ChargingStation, requestId: number, - signature?: string + firmware: FirmwareType ) => Promise }, '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') + }) + }) + }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20RequestService-SignCertificate.test.ts b/tests/charging-station/ocpp/2.0/OCPP20RequestService-SignCertificate.test.ts index 10d6eaad..f64490e1 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20RequestService-SignCertificate.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20RequestService-SignCertificate.test.ts @@ -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({ 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({ + 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({ + 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({ + 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({ + 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({ + 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)') }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts index a92bc75b..15de7686 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts @@ -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 } + }), } } diff --git a/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts index 199b7674..432b424f 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts @@ -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() + } + }) }) diff --git a/tests/helpers/TestLifecycleHelpers.ts b/tests/helpers/TestLifecycleHelpers.ts index 0721a629..883c0639 100644 --- a/tests/helpers/TestLifecycleHelpers.ts +++ b/tests/helpers/TestLifecycleHelpers.ts @@ -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 => + new Promise(resolve => { + setImmediate(resolve) + }) + /** * Suspends execution for the specified number of milliseconds. * @param ms - Duration to sleep in milliseconds. -- 2.43.0