--- /dev/null
+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-----`
+}
): DeleteCertificateResult | Promise<DeleteCertificateResult>
getInstalledCertificates(
stationHashId: string,
- filterTypes?: InstallCertificateUseEnumType[]
+ filterTypes?: InstallCertificateUseEnumType[],
+ hashAlgorithm?: HashAlgorithmEnumType
): GetInstalledCertificatesResult | Promise<GetInstalledCertificatesResult>
storeCertificate(
stationHashId: string,
pemData: string
): Promise<StoreCertificateResult> | StoreCertificateResult
validateCertificateFormat(pemData: unknown): boolean
+ validateCertificateX509(pem: string): ValidateCertificateX509Result
}
/**
success: boolean
}
+/**
+ * Result type for X.509 certificate validation
+ */
+export interface ValidateCertificateX509Result {
+ reason?: string
+ valid: boolean
+}
+
/**
* OCPP 2.0 Certificate Manager
*
* Gets installed certificates for a charging station
* @param stationHashId - Charging station unique identifier
* @param filterTypes - Optional array of certificate types to filter
+ * @param hashAlgorithm
* @returns List of installed certificate hash data chains
*/
public async getInstalledCertificates (
stationHashId: string,
- filterTypes?: InstallCertificateUseEnumType[]
+ filterTypes?: InstallCertificateUseEnumType[],
+ hashAlgorithm?: HashAlgorithmEnumType
): Promise<GetInstalledCertificatesResult> {
const certificateHashDataChain: CertificateHashDataChainType[] = []
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,
)
}
+ /**
+ * 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.
type EvseStatus,
FirmwareStatus,
FirmwareStatusEnumType,
+ type FirmwareType,
GenericDeviceModelStatusEnumType,
GenericStatus,
GetCertificateIdUseEnumType,
OCPP20RequiredVariableName,
type OCPP20ResetRequest,
type OCPP20ResetResponse,
+ type OCPP20SecurityEventNotificationRequest,
+ type OCPP20SecurityEventNotificationResponse,
type OCPP20SetNetworkProfileRequest,
type OCPP20SetNetworkProfileResponse,
type OCPP20SetVariablesRequest,
sleep,
validateUUID,
} from '../../../utils/index.js'
+import { getConfigurationKey } from '../../ConfigurationKeyUtils.js'
import {
getIdTagsFile,
hasPendingReservation,
export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
protected payloadValidatorFunctions: Map<OCPP20IncomingRequestCommand, ValidateFunction<JsonType>>
+ private activeFirmwareUpdateAbortController: AbortController | undefined
+
+ private activeFirmwareUpdateRequestId: number | undefined
+
private readonly incomingRequestHandlers: Map<
OCPP20IncomingRequestCommand,
IncomingRequestHandler
this.simulateFirmwareUpdateLifecycle(
chargingStation,
request.requestId,
- request.firmware.signature
+ request.firmware
).catch((error: unknown) => {
logger.error(
`${chargingStation.logPrefix()} ${moduleName}.constructor: UpdateFirmware lifecycle error:`,
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, [
}
}
+ // 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 ?? '',
`${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)`
)
return {
status: CustomerInformationStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'Neither clear nor report flag is set in CustomerInformation request',
+ reasonCode: ReasonCodeEnumType.InvalidValue,
+ },
}
}
/**
* 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
}
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
}
}
+ 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 ?? '',
}
/**
- * 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,
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,
}
}
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: `Connector ${connectorId.toString()} already has an active transaction`,
+ reasonCode: ReasonCodeEnumType.TxInProgress,
+ },
transactionId: generateUUID(),
}
}
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'Authorization error occurred',
+ reasonCode: ReasonCodeEnumType.InternalError,
+ },
transactionId: generateUUID(),
}
}
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: `IdToken ${idToken.idToken} is not authorized`,
+ reasonCode: ReasonCodeEnumType.InvalidIdToken,
+ },
transactionId: generateUUID(),
}
}
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'Group authorization error occurred',
+ reasonCode: ReasonCodeEnumType.InternalError,
+ },
transactionId: generateUUID(),
}
}
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: `GroupIdToken ${groupIdToken.idToken} is not authorized`,
+ reasonCode: ReasonCodeEnumType.InvalidIdToken,
+ },
transactionId: generateUUID(),
}
}
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'ChargingProfile must have purpose TxProfile',
+ reasonCode: ReasonCodeEnumType.InvalidProfile,
+ },
transactionId: generateUUID(),
}
}
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'ChargingProfile transactionId must not be set',
+ reasonCode: ReasonCodeEnumType.InvalidValue,
+ },
transactionId: generateUUID(),
}
}
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'Charging profile validation error',
+ reasonCode: ReasonCodeEnumType.InternalError,
+ },
transactionId: generateUUID(),
}
}
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'Invalid charging profile',
+ reasonCode: ReasonCodeEnumType.InvalidProfile,
+ },
transactionId: generateUUID(),
}
}
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'Error starting transaction',
+ reasonCode: ReasonCodeEnumType.InternalError,
+ },
transactionId: generateUUID(),
}
}
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'Invalid transaction ID format',
+ reasonCode: ReasonCodeEnumType.InvalidValue,
+ },
}
}
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: `Transaction ID ${transactionId as string} does not exist`,
+ reasonCode: ReasonCodeEnumType.TxNotFound,
+ },
}
}
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: `Transaction ID ${transactionId as string} does not exist on any connector`,
+ reasonCode: ReasonCodeEnumType.TxNotFound,
+ },
}
}
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'Remote stop transaction rejected',
+ reasonCode: ReasonCodeEnumType.Unspecified,
+ },
}
} catch (error) {
logger.error(
)
return {
status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'Error occurred during remote stop transaction',
+ reasonCode: ReasonCodeEnumType.InternalError,
+ },
}
}
}
`${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,
}
}
}
+ 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
chargingStation: ChargingStation,
requestId: number
): Promise<void> {
- const notifyCustomerInformationRequest: OCPP20NotifyCustomerInformationRequest = {
- data: '',
- generatedAt: new Date(),
- requestId,
- seqNo: 0,
- tbc: false,
+ // Simulator has no persistent customer data, so send empty data.
+ // Uses pagination pattern (seqNo/tbc) consistent with sendNotifyReportRequest.
+ const dataChunks = ['']
+
+ for (let seqNo = 0; seqNo < dataChunks.length; seqNo++) {
+ const isLastChunk = seqNo === dataChunks.length - 1
+
+ const notifyCustomerInformationRequest: OCPP20NotifyCustomerInformationRequest = {
+ data: dataChunks[seqNo],
+ generatedAt: new Date(),
+ requestId,
+ seqNo,
+ tbc: !isLastChunk,
+ }
+
+ await chargingStation.ocppRequestService.requestHandler<
+ OCPP20NotifyCustomerInformationRequest,
+ OCPP20NotifyCustomerInformationResponse
+ >(
+ chargingStation,
+ OCPP20RequestCommand.NOTIFY_CUSTOMER_INFORMATION,
+ notifyCustomerInformationRequest
+ )
+
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.sendNotifyCustomerInformation: NotifyCustomerInformation sent seqNo=${seqNo.toString()} for requestId ${requestId.toString()} (tbc=${(!isLastChunk).toString()})`
+ )
}
- await chargingStation.ocppRequestService.requestHandler<
- OCPP20NotifyCustomerInformationRequest,
- OCPP20NotifyCustomerInformationResponse
- >(
- chargingStation,
- OCPP20RequestCommand.NOTIFY_CUSTOMER_INFORMATION,
- notifyCustomerInformationRequest
+
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.sendNotifyCustomerInformation: Completed NotifyCustomerInformation for requestId ${requestId.toString()} in ${dataChunks.length.toString()} message(s)`
)
}
this.reportDataCache.delete(requestId)
}
+ private sendSecurityEventNotification (
+ chargingStation: ChargingStation,
+ type: string,
+ techInfo?: string
+ ): Promise<OCPP20SecurityEventNotificationResponse> {
+ return chargingStation.ocppRequestService.requestHandler<
+ OCPP20SecurityEventNotificationRequest,
+ OCPP20SecurityEventNotificationResponse
+ >(chargingStation, OCPP20RequestCommand.SECURITY_EVENT_NOTIFICATION, {
+ timestamp: new Date(),
+ type,
+ ...(techInfo !== undefined && { techInfo }),
+ })
+ }
+
/**
- * Simulates a firmware update lifecycle through status progression using chained setTimeout calls.
- * Sequence: Downloading → Downloaded → [SignatureVerified if signature present] → Installing → Installed
+ * Simulates a firmware update lifecycle through status progression per OCPP 2.0.1 L01/L02.
+ * Sequence: [DownloadScheduled] → Downloading → Downloaded/DownloadFailed →
+ * [SignatureVerified] → [InstallScheduled] → Installing → Installed
* @param chargingStation - The charging station instance
* @param requestId - The request ID from the UpdateFirmware request
- * @param signature - Optional firmware signature; triggers SignatureVerified step if present
+ * @param firmware - The firmware details including location, dates, and optional signature
*/
private async simulateFirmwareUpdateLifecycle (
chargingStation: ChargingStation,
requestId: number,
- signature?: string
+ firmware: FirmwareType
): Promise<void> {
+ const { installDateTime, location, retrieveDateTime, signature } = firmware
+
+ // H10: Set up abort controller for cancellation support
+ const abortController = new AbortController()
+ this.activeFirmwareUpdateAbortController = abortController
+ this.activeFirmwareUpdateRequestId = requestId
+
+ const checkAborted = (): boolean => abortController.signal.aborted
+
+ // C12: If retrieveDateTime is in the future, send DownloadScheduled and wait
+ const now = Date.now()
+ const retrieveTime = new Date(retrieveDateTime).getTime()
+ if (retrieveTime > now) {
+ await this.sendFirmwareStatusNotification(
+ chargingStation,
+ FirmwareStatusEnumType.DownloadScheduled,
+ requestId
+ )
+ await sleep(retrieveTime - now)
+ if (checkAborted()) return
+ }
+
await this.sendFirmwareStatusNotification(
chargingStation,
FirmwareStatusEnumType.Downloading,
requestId
)
- await sleep(1000)
+ await sleep(2000)
+ if (checkAborted()) return
+
+ // H9: If firmware location is empty or malformed, send DownloadFailed and stop
+ if (location.trim() === '' || !this.isValidFirmwareLocation(location)) {
+ await this.sendFirmwareStatusNotification(
+ chargingStation,
+ FirmwareStatusEnumType.DownloadFailed,
+ requestId
+ )
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.simulateFirmwareUpdateLifecycle: Download failed for requestId ${requestId.toString()} - invalid location '${location}'`
+ )
+ this.clearActiveFirmwareUpdate(requestId)
+ return
+ }
+
await this.sendFirmwareStatusNotification(
chargingStation,
FirmwareStatusEnumType.Downloaded,
if (signature != null) {
await sleep(500)
+ if (checkAborted()) return
await this.sendFirmwareStatusNotification(
chargingStation,
FirmwareStatusEnumType.SignatureVerified,
)
}
- 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,
)
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()}`
)
import type { ValidateFunction } from 'ajv'
-import { generateKeyPairSync } from 'node:crypto'
-
import type { ChargingStation } from '../../../charging-station/index.js'
import type { OCPPResponseService } from '../OCPPResponseService.js'
} 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'
* 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
/**
* 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,
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(
Object.keys(VARIABLE_REGISTRY).map(k => k.split('::')[0])
)
- private readonly invalidVariables = new Set<string>() // composite key (lower case)
- private readonly maxSetOverrides = new Map<string, string>() // composite key (lower case)
- private readonly minSetOverrides = new Map<string, string>() // composite key (lower case)
- private readonly runtimeOverrides = new Map<string, string>() // composite key (lower case)
+ private readonly invalidVariables = new Map<string, Set<string>>() // stationId → composite keys (lower case)
+ private readonly maxSetOverrides = new Map<string, string>() // composite key (lower case) → value
+ private readonly minSetOverrides = new Map<string, string>() // composite key (lower case) → value
+ private readonly runtimeOverrides = new Map<string, string>() // composite key (lower case) → value
+ private readonly validatedStations = new Set<string>() // stationId
private constructor () {
/* This is intentional */
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()
}
setVariableData: OCPP20SetVariableDataType[]
): OCPP20SetVariableResultType[] {
this.validatePersistentMappings(chargingStation)
+
+ // Collect paired MinSet/MaxSet entries for atomic cross-validation
+ const pairedBounds = new Map<string, { maxValue?: string; minValue?: string }>()
+ for (const variableData of setVariableData) {
+ const resolvedAttr = variableData.attributeType ?? AttributeEnumType.Actual
+ if (resolvedAttr !== AttributeEnumType.MinSet && resolvedAttr !== AttributeEnumType.MaxSet) {
+ continue
+ }
+ const varKey = buildCaseInsensitiveCompositeKey(
+ variableData.component.name,
+ variableData.component.instance,
+ variableData.variable.name
+ )
+ const entry = pairedBounds.get(varKey) ?? {}
+ if (resolvedAttr === AttributeEnumType.MinSet) {
+ entry.minValue = variableData.attributeValue
+ } else {
+ entry.maxValue = variableData.attributeValue
+ }
+ pairedBounds.set(varKey, entry)
+ }
+
+ // Pre-apply coherent MinSet/MaxSet pairs so per-item cross-check sees paired values
+ const savedOverrides = new Map<
+ string,
+ { prevMax: string | undefined; prevMin: string | undefined }
+ >()
+ for (const [varKey, pair] of pairedBounds) {
+ if (pair.minValue == null || pair.maxValue == null) continue
+ const newMin = convertToIntOrNaN(pair.minValue)
+ const newMax = convertToIntOrNaN(pair.maxValue)
+ if (Number.isNaN(newMin) || Number.isNaN(newMax) || newMin > newMax) continue
+ savedOverrides.set(varKey, {
+ prevMax: this.maxSetOverrides.get(varKey),
+ prevMin: this.minSetOverrides.get(varKey),
+ })
+ this.minSetOverrides.set(varKey, pair.minValue)
+ this.maxSetOverrides.set(varKey, pair.maxValue)
+ }
+
const results: OCPP20SetVariableResultType[] = []
for (const variableData of setVariableData) {
try {
})
}
}
+
+ // 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
`${chargingStation.logPrefix()} Added missing configuration key for variable '${configurationKeyName}' with default '${defaultValue}'`
)
} else {
- this.invalidVariables.add(variableKey)
+ invalidVariables.add(variableKey)
logger.error(
`${chargingStation.logPrefix()} Missing configuration key mapping and no default for variable '${configurationKeyName}'`
)
}
}
}
+ this.validatedStations.add(stationId)
+ }
+
+ private getInvalidVariables (stationId: string): Set<string> {
+ let set = this.invalidVariables.get(stationId)
+ if (set == null) {
+ set = new Set<string>()
+ this.invalidVariables.set(stationId, set)
+ }
+ return set
+ }
+
+ private getStationId (chargingStation: ChargingStation): string {
+ const stationId = chargingStation.stationInfo?.hashId
+ if (stationId == null) {
+ throw new Error('ChargingStation has no stationInfo.hashId, cannot identify station')
+ }
+ return stationId
}
private getVariable (
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(
component.instance,
variable.name
)
- if (this.invalidVariables.has(variableKey)) {
+ if (invalidVariables.has(variableKey)) {
return this.rejectGet(
variable,
component,
)
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
): 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(
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,
'Variable mapping invalid (startup self-check failed)'
)
} else {
- this.invalidVariables.delete(variableKey)
+ invalidVariables.delete(variableKey)
}
}
)
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
}
export enum FirmwareStatusEnumType {
+ AcceptedCanceled = 'AcceptedCanceled',
Downloaded = 'Downloaded',
DownloadFailed = 'DownloadFailed',
Downloading = 'Downloading',
FwUpdateInProgress = 'FwUpdateInProgress',
InternalError = 'InternalError',
InvalidCertificate = 'InvalidCertificate',
+ InvalidConfSlot = 'InvalidConfSlot',
InvalidCSR = 'InvalidCSR',
InvalidIdToken = 'InvalidIdToken',
InvalidMessageSeq = 'InvalidMessageSeq',
+ InvalidNetworkConf = 'InvalidNetworkConf',
InvalidProfile = 'InvalidProfile',
InvalidSchedule = 'InvalidSchedule',
InvalidStackLevel = 'InvalidStackLevel',
MissingParam = 'MissingParam',
NoCable = 'NoCable',
NoError = 'NoError',
+ NoSecurityDowngrade = 'NoSecurityDowngrade',
NotEnabled = 'NotEnabled',
NotFound = 'NotFound',
+ NotSupported = 'NotSupported',
OutOfMemory = 'OutOfMemory',
OutOfStorage = 'OutOfStorage',
ReadOnly = 'ReadOnly',
--- /dev/null
+/**
+ * @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)
+ })
+ })
+})
import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
import {
EMPTY_PEM_CERTIFICATE,
+ EXPIRED_X509_PEM_CERTIFICATE,
INVALID_PEM_CERTIFICATE_MISSING_MARKERS,
INVALID_PEM_WRONG_MARKERS,
VALID_PEM_CERTIFICATE_EXTENDED,
+ VALID_X509_PEM_CERTIFICATE,
} from './OCPP20CertificateTestData.js'
const TEST_STATION_HASH_ID = 'test-station-hash-12345'
const TEST_CERT_TYPE = InstallCertificateUseEnumType.CSMSRootCertificate
-// eslint-disable-next-line @typescript-eslint/no-unused-vars -- kept for future assertions
-const _EXPECTED_HASH_DATA = {
- hashAlgorithm: HashAlgorithmEnumType.SHA256,
- issuerKeyHash: /^[a-fA-F0-9]+$/,
- issuerNameHash: /^[a-fA-F0-9]+$/,
- serialNumber: '<any-string>',
-}
-
await describe('I02-I04 - ISO15118 Certificate Management', async () => {
afterEach(async () => {
await rm(`dist/assets/configurations/${TEST_STATION_HASH_ID}`, {
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'))
+ })
+ })
})
* 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-----`
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 {
GenericStatus,
type OCPP20CertificateSignedRequest,
type OCPP20CertificateSignedResponse,
+ OCPP20RequestCommand,
OCPPVersion,
} from '../../../../src/types/index.js'
import { Constants } from '../../../../src/utils/index.js'
} from './OCPP20CertificateTestData.js'
import {
createMockCertificateManager,
+ createMockStationWithRequestTracking,
createStationWithCertificateManager,
} from './OCPP20TestUtils.js'
await describe('I04 - CertificateSigned', async () => {
let station: ChargingStation
let stationWithCertManager: ChargingStationWithCertificateManager
- let incomingRequestService: OCPP20IncomingRequestService
let testableService: ReturnType<typeof createTestableIncomingRequestService>
beforeEach(() => {
createMockCertificateManager()
)
station.closeWSConnection = mock.fn()
- incomingRequestService = new OCPP20IncomingRequestService()
- testableService = createTestableIncomingRequestService(incomingRequestService)
+ testableService = createTestableIncomingRequestService(new OCPP20IncomingRequestService())
})
afterEach(() => {
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'))
+ })
+ })
})
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'
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,
})
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', () => {
const request: OCPP20CustomerInformationRequest = {
clear: false,
+ idToken: { idToken: 'TOKEN_001', type: OCPP20IdTokenEnumType.Central },
report: true,
requestId: 20,
}
const request: OCPP20CustomerInformationRequest = {
clear: false,
+ idToken: { idToken: 'TOKEN_001', type: OCPP20IdTokenEnumType.Central },
report: true,
requestId: 99,
}
import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
import {
DeleteCertificateStatusEnumType,
+ GetCertificateIdUseEnumType,
HashAlgorithmEnumType,
type OCPP20DeleteCertificateRequest,
type OCPP20DeleteCertificateResponse,
import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
import {
+ createMockCertificateHashDataChain,
createMockCertificateManager,
createStationWithCertificateManager,
} from './OCPP20TestUtils.js'
let station: ChargingStation
let stationWithCertManager: ChargingStationWithCertificateManager
- let incomingRequestService: OCPP20IncomingRequestService
let testableService: ReturnType<typeof createTestableIncomingRequestService>
beforeEach(() => {
createMockCertificateManager()
)
- incomingRequestService = new OCPP20IncomingRequestService()
- testableService = createTestableIncomingRequestService(incomingRequestService)
+ testableService = createTestableIncomingRequestService(new OCPP20IncomingRequestService())
})
await describe('Valid Certificate Deletion', 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)
+ })
+ })
})
/**
* @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'
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
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)
+ })
+ })
+ })
})
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,
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,
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',
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)
})
})
/**
* @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'
import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
import {
+ FirmwareStatusEnumType,
+ type FirmwareType,
OCPP20IncomingRequestCommand,
+ OCPP20RequestCommand,
type OCPP20UpdateFirmwareRequest,
type OCPP20UpdateFirmwareResponse,
OCPPVersion,
+ ReasonCodeEnumType,
UpdateFirmwareStatusEnumType,
} from '../../../../src/types/index.js'
import { Constants } from '../../../../src/utils/index.js'
-import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import {
+ flushMicrotasks,
+ standardCleanup,
+ withMockTimers,
+} from '../../../helpers/TestLifecycleHelpers.js'
import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+import {
+ createMockCertificateManager,
+ createMockStationWithRequestTracking,
+ createStationWithCertificateManager,
+} from './OCPP20TestUtils.js'
-await describe('J02 - UpdateFirmware', async () => {
+await describe('L01/L02 - UpdateFirmware', async () => {
let station: ChargingStation
let testableService: ReturnType<typeof createTestableIncomingRequestService>
simulateFirmwareUpdateLifecycle: (
chargingStation: ChargingStation,
requestId: number,
- signature?: string
+ firmware: FirmwareType
) => Promise<void>
},
'simulateFirmwareUpdateLifecycle',
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', () => {
simulateFirmwareUpdateLifecycle: (
chargingStation: ChargingStation,
requestId: number,
- signature?: string
+ firmware: FirmwareType
) => Promise<void>
},
'simulateFirmwareUpdateLifecycle',
simulateFirmwareUpdateLifecycle: (
chargingStation: ChargingStation,
requestId: number,
- signature?: string
+ firmware: FirmwareType
) => Promise<void>
},
'simulateFirmwareUpdateLifecycle',
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')
+ })
+ })
+ })
})
const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20SignCertificateRequest
assert.notStrictEqual(sentPayload.csr, undefined)
- assert.ok(sentPayload.csr.includes('-----BEGIN CERTIFICATE REQUEST-----'))
- assert.ok(sentPayload.csr.includes('-----END CERTIFICATE REQUEST-----'))
+ assert.ok(sentPayload.csr.startsWith('-----BEGIN CERTIFICATE REQUEST-----'))
+ assert.ok(sentPayload.csr.endsWith('-----END CERTIFICATE REQUEST-----'))
})
- await it('should include OrganizationName from SecurityCtrlr config in CSR', async () => {
+ await it('should generate CSR starting with BEGIN CERTIFICATE REQUEST marker', async () => {
const { sendMessageMock, service } =
createTestableRequestService<OCPP20SignCertificateResponse>({
sendMessageResponse: {
)
const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20SignCertificateRequest
- assert.notStrictEqual(sentPayload.csr, undefined)
- assert.ok(sentPayload.csr.includes('-----BEGIN CERTIFICATE REQUEST-----'))
+ assert.ok(sentPayload.csr.startsWith('-----BEGIN CERTIFICATE REQUEST-----\n'))
+ })
+
+ await it('should generate CSR ending with END CERTIFICATE REQUEST marker', async () => {
+ const { sendMessageMock, service } =
+ createTestableRequestService<OCPP20SignCertificateResponse>({
+ sendMessageResponse: {
+ status: GenericStatus.Accepted,
+ },
+ })
+
+ await service.requestSignCertificate(
+ station,
+ CertificateSigningUseEnumType.ChargingStationCertificate
+ )
+
+ const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20SignCertificateRequest
+ assert.ok(sentPayload.csr.endsWith('\n-----END CERTIFICATE REQUEST-----'))
+ })
+
+ await it('should generate CSR body with valid Base64 encoding', async () => {
+ const { sendMessageMock, service } =
+ createTestableRequestService<OCPP20SignCertificateResponse>({
+ sendMessageResponse: {
+ status: GenericStatus.Accepted,
+ },
+ })
+
+ await service.requestSignCertificate(
+ station,
+ CertificateSigningUseEnumType.ChargingStationCertificate
+ )
- const csrRegex =
- /-----BEGIN CERTIFICATE REQUEST-----\n(.+?)\n-----END CERTIFICATE REQUEST-----/
- const csrExecResult = csrRegex.exec(sentPayload.csr)
- assert.notStrictEqual(csrExecResult, undefined)
- const csrData = csrExecResult?.[1]
- const decodedCsr = Buffer.from(csrData ?? '', 'base64').toString('utf-8')
- assert.ok(decodedCsr.includes('O=Test Organization Inc.'))
+ const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20SignCertificateRequest
+ const csrLines = sentPayload.csr.split('\n')
+ const base64Body = csrLines.slice(1, -1).join('')
+ assert.ok(/^[A-Za-z0-9+/]+=*$/.test(base64Body), 'CSR body must be valid Base64')
+ const decoded = Buffer.from(base64Body, 'base64')
+ assert.ok(decoded.length > 0, 'Decoded CSR must not be empty')
+ })
+
+ await it('should include station ID in CSR subject DN', async () => {
+ const { sendMessageMock, service } =
+ createTestableRequestService<OCPP20SignCertificateResponse>({
+ sendMessageResponse: {
+ status: GenericStatus.Accepted,
+ },
+ })
+
+ await service.requestSignCertificate(
+ station,
+ CertificateSigningUseEnumType.ChargingStationCertificate
+ )
+
+ const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20SignCertificateRequest
+ const csrLines = sentPayload.csr.split('\n')
+ const base64Body = csrLines.slice(1, -1).join('')
+ const derBytes = Buffer.from(base64Body, 'base64')
+ const stationId = station.stationInfo?.chargingStationId ?? ''
+ assert.ok(stationId.length > 0, 'Station ID must not be empty')
+ assert.ok(
+ derBytes.includes(Buffer.from(stationId, 'utf-8')),
+ 'CSR DER must contain station ID'
+ )
+ })
+
+ await it('should include OrganizationName in CSR subject DN', async () => {
+ const { sendMessageMock, service } =
+ createTestableRequestService<OCPP20SignCertificateResponse>({
+ sendMessageResponse: {
+ status: GenericStatus.Accepted,
+ },
+ })
+
+ await service.requestSignCertificate(
+ station,
+ CertificateSigningUseEnumType.ChargingStationCertificate
+ )
+
+ const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20SignCertificateRequest
+ const csrLines = sentPayload.csr.split('\n')
+ const base64Body = csrLines.slice(1, -1).join('')
+ const derBytes = Buffer.from(base64Body, 'base64')
+ assert.ok(
+ derBytes.includes(Buffer.from(MOCK_ORGANIZATION_NAME, 'utf-8')),
+ 'CSR DER must contain organization name'
+ )
+ })
+
+ await it('should generate valid ASN.1 DER structure starting with SEQUENCE tag', async () => {
+ const { sendMessageMock, service } =
+ createTestableRequestService<OCPP20SignCertificateResponse>({
+ sendMessageResponse: {
+ status: GenericStatus.Accepted,
+ },
+ })
+
+ await service.requestSignCertificate(
+ station,
+ CertificateSigningUseEnumType.ChargingStationCertificate
+ )
+
+ const sentPayload = sendMessageMock.mock.calls[0].arguments[2] as OCPP20SignCertificateRequest
+ const csrLines = sentPayload.csr.split('\n')
+ const base64Body = csrLines.slice(1, -1).join('')
+ const derBytes = Buffer.from(base64Body, 'base64')
+ assert.strictEqual(derBytes[0], 0x30, 'CSR DER must start with SEQUENCE tag (0x30)')
})
})
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 }
}
// ============================================================================
cert.includes('-----BEGIN CERTIFICATE-----') && cert.includes('-----END CERTIFICATE-----')
)
}),
+ validateCertificateX509: mock.fn(() => {
+ return options.validateCertificateX509Result ?? { valid: true }
+ }),
}
}
afterEach(() => {
standardCleanup()
OCPP20VariableManager.getInstance().resetRuntimeOverrides()
+ OCPP20VariableManager.getInstance().invalidateMappingsCache()
})
await it('should return same instance when getInstance() called multiple times', () => {
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()
+ }
+ })
})
MockIdTagsCache.resetInstance()
}
+/**
+ * Flush all pending microtasks by yielding to the event loop.
+ * setImmediate fires after all microtasks in the current event loop iteration are drained.
+ * Use this in tests that need to await async side effects triggered by synchronous calls
+ * (e.g. event emitters that fire async handlers).
+ */
+export const flushMicrotasks = (): Promise<void> =>
+ new Promise<void>(resolve => {
+ setImmediate(resolve)
+ })
+
/**
* Suspends execution for the specified number of milliseconds.
* @param ms - Duration to sleep in milliseconds.