Skip to content

.pr_agent_auto_best_practices

qodo-merge-bot edited this page Jun 19, 2026 · 7 revisions

Pattern 1: Validate externally supplied inputs (including config values and request DTO fields) for null/blank/range before dereferencing them or using them in SQL predicates; fail fast with a semantically correct BadRequest-style error.

Example code before:

// params come from HTTP/OCPP/config
if (params.getTimestamp().getMillis() < 0) { ... }

ctx.selectFrom(TABLE)
   .where(TABLE.CHARGE_BOX_ID.eq(chargeBoxId))   // chargeBoxId may be null/blank
   .fetch();

Example code after:

if (params == null || !params.isSetTimestamp()) {
    throw new SteveException.BadRequest("'timestamp' must be provided");
}
if (StringUtils.isBlank(chargeBoxId)) {
    throw new SteveException.BadRequest("'chargeBoxId' must be provided");
}

ctx.selectFrom(TABLE)
   .where(TABLE.CHARGE_BOX_ID.eq(chargeBoxId))
   .fetch();
Relevant past accepted suggestions:
Suggestion 1: [reliability] `readTree(value.data())` lacks validation
`readTree(value.data())` lacks validation `ChargePoint.Details#getConfigValueSafely` parses `chargeBox.getOcppConfiguration().data()` via `mapper.readTree(...)` without checking for null/blank content and without handling JSON parsing failures, which can throw at runtime during page rendering. This violates input-validation and error-semantics expectations because malformed/empty persisted configuration can propagate as an externally visible 500-style failure instead of a controlled outcome.

Issue description

ChargePoint.Details#getConfigValueSafely calls mapper.readTree(value.data()) on persisted ocppConfiguration content without validating that the JSON string is non-null/non-blank and without catching parse exceptions. If the stored configuration is empty or malformed/unexpected, the getter can throw during view evaluation and cause an externally visible 500-style failure.

Issue Context

This getter is used directly by the charge point details view via JSP expressions (e.g., ${cp.cpoName}), so runtime exceptions during JSON parsing will bubble up as page-rendering failures. The project also has other usage that acknowledges readTree(...) is throwable (e.g., declaring throws JacksonException), underscoring the need for explicit error handling here.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/repository/dto/ChargePoint.java[52-71]

Suggestion 2: [reliability] `insertIgnoreConnector` lacks input validation
`insertIgnoreConnector` lacks input validation `insertIgnoreConnector()` uses externally supplied `chargeBoxIdentity` directly in SQL predicates without validating null/blank, which can lead to ambiguous queries or runtime failures instead of a clear validation error.

Issue description

insertIgnoreConnector() accepts chargeBoxIdentity (request/station-derived) and uses it to build SQL predicates without validating null/blank. This violates the input validation requirement and can cause unclear behavior or runtime errors.

Issue Context

The compliance requirement expects fail-fast validation (e.g., IllegalArgument/BadRequest-style) before dereferencing/using inputs in SQL predicate construction.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/repository/impl/OcppServerRepositoryImpl.java[434-455]

Suggestion 3: [correctness] `getJsonPath` null address deref
`getJsonPath` null address deref `Helpers.getJsonPath()` dereferences `serverProperties.getAddress()` via `toString()` without null checks, which can throw a NullPointerException when the server bind address is unset (common default). This violates the requirement to validate externally supplied/configured inputs before dereferencing.

Issue description

Helpers.getJsonPath(ServerProperties) calls serverProperties.getAddress().toString() without checking for null, which can cause NPE when address is not configured.

Issue Context

ServerProperties values come from external configuration/defaults; address may be unset.

Fix Focus Areas

  • src/test/java/de/rwth/idsg/steve/utils/Helpers.java[73-90]

Suggestion 4: [reliability] `normalizePemInput()` lacks null check
`normalizePemInput()` lacks null check The new `normalizePemInput` dereferences `pem` via `pem.contains("%")` without a null check, which can trigger an NPE when PEM input is missing. This violates the requirement to validate externally supplied inputs before dereferencing.

Issue description

normalizePemInput dereferences pem without a null check, risking NPE on missing PEM inputs.

Issue Context

PEM inputs can be externally supplied (e.g., headers/config). Compliance requires validating before dereferencing.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/utils/CertificateUtils.java[240-246]

Suggestion 5: [reliability] `idTagFromTransaction` not validated
`idTagFromTransaction` not validated `idTagFromTransaction` (derived from external OCPP input) is used to build SQL predicates without null/blank validation, which can yield ambiguous query behavior (e.g., matching `NULL` or silently updating nothing). This violates the requirement to validate externally supplied inputs (including when used in SQL predicate construction) before use and fail fast with a clear error.

Issue description

idTagFromTransaction is used to construct SQL conditions without null/blank validation, which can cause ambiguous behavior (e.g., IS NULL-style predicates or silent no-op updates) instead of failing fast with a clear validation error.

Issue Context

idTagFromTransaction originates from transaction input (p.getIdTag()), i.e., external/request-derived data.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/repository/impl/ReservationRepositoryImpl.java[174-199]

Suggestion 6: [reliability] `updateSecurityProfile()` lacks validation
`updateSecurityProfile()` lacks validation `ChargePointService.updateSecurityProfile()` parses `securityProfile` using `Integer.valueOf(securityProfile)` without null/blank/range validation, so externally supplied values can trigger `NumberFormatException`/`IllegalArgumentException` instead of a clear validation error. This can cause ambiguous 500-style failures and prevent accepted OCPP changes from being persisted correctly.

Issue description

updateSecurityProfile(chargeBoxId, securityProfile) does not validate securityProfile and can throw parsing exceptions (NumberFormatException/IllegalArgumentException) instead of a clear client-facing validation error.

Issue Context

This method is invoked when persisting accepted OCPP ChangeConfiguration updates for key SecurityProfile (via ChangeConfigurationTask), so invalid values can break the persistence path and produce unclear errors.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/service/ChargePointService.java[124-127]

Suggestion 7: [correctness] `validateSecurityEvent` lacks null checks
`validateSecurityEvent` lacks null checks `validateSecurityEvent(SecurityEventNotification params)` dereferences `params.getTimestamp()` without validating `params` and timestamp presence, which can cause NPEs for externally supplied inputs. This violates the input-validation requirement before using values in comparisons.

Issue description

validateSecurityEvent(...) uses params.getTimestamp().getMillis() without null/presence checks, risking NPE and unpredictable behavior for invalid external inputs.

Issue Context

Other validators in this class (e.g., validateStatusNotification) check timestamp presence (isSetTimestamp()) before using it; this method should follow the same pattern.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/service/CentralSystemService16_ServiceValidator.java[76-80]

Suggestion 8: [reliability] `validateStatusNotification` missing null guard
`validateStatusNotification` missing null guard The new validator dereferences externally supplied `params` without checking for null, which can produce an NPE instead of a clear, semantically correct validation failure. This violates the requirement to validate externally supplied inputs before use in comparisons.

Issue description

validateStatusNotification(StatusNotificationRequest params) does not validate params for null before dereferencing it, risking an NPE and unclear failure behavior.

Issue Context

StatusNotificationRequest is externally supplied and should be validated/normalized before comparisons or dereferences.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/service/CentralSystemService16_ServiceValidator.java[59-72]

Suggestion 9: [reliability] `from/to` nulls not validated
`from/to` nulls not validated `TransactionReportService.getTransactionReportCsv()` converts possibly-null `OffsetDateTime` values into `DateTime` (via `toDateTime` returning null) and passes them into `getStoppedTransactions()`, which immediately does `to.isAfter(from)` and uses them in query predicates. This can cause a `NullPointerException`/ambiguous query behavior instead of a clear 400-style validation error for externally supplied report timestamps.

Issue description

The report CSV path can pass null from/to values into getStoppedTransactions(), where they are used in a comparison and SQL predicates without null validation, risking NullPointerException and unclear error responses.

Issue Context

DateTimeUtils.toDateTime(OffsetDateTime) returns null for null input, and the service/repository methods do not fail fast with a semantically correct exception when from/to are missing.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/service/TransactionReportService.java[38-43]
  • src/main/java/de/rwth/idsg/steve/repository/impl/TransactionRepositoryImpl.java[279-287]
  • src/main/java/de/rwth/idsg/steve/utils/DateTimeUtils.java[93-100]

Implementation notes

  • Enforce non-null with explicit validation (e.g., Objects.requireNonNull or throw SteveException.BadRequest("'from' must be provided"), same for to).
  • Keep the existing ordering check, but run it only after non-null validation.
  • Optionally, make toDateTime throw a clear exception instead of returning null if null inputs are never valid in this code path.

Suggestion 10: [correctness] Null `MeterValue` not handled
Null `MeterValue` not handled `meterValues` is streamed and dereferenced via `MeterValue::getTimestamp` without filtering out possible null elements, which can cause an NPE instead of a clear validation error. Additionally, null timestamps are silently dropped (unless all are null), allowing invalid external input to bypass validation logic.

Issue description

validateMeterValuesInternal can throw an NPE because it calls MeterValue::getTimestamp on elements of an externally supplied list without filtering out null elements first. It also silently ignores null timestamps (unless all timestamps are null), which lets invalid external input bypass validation.

Issue Context

Compliance requires sanitizing externally supplied collections (treat null as empty, filter null elements) and failing fast with clear/semantically correct validation errors.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/service/CentralSystemService16_ServiceValidator.java[115-123]

Suggestion 11: [correctness] `chargeBoxId` not null-checked
`chargeBoxId` not null-checked The newly introduced `chargeBoxId` parameter is used in the database predicate without an explicit null/empty check. If `chargeBoxId` is null (or blank), the query behavior can become incorrect and may undermine the intended ownership constraint or lead to null-driven failures.

Issue description

chargeBoxId is a new input to getTransaction(...) but is not explicitly validated for null (and potentially blank) before being used in the query predicate.

Issue Context

Null/blank chargeBoxId can lead to incorrect query behavior and null-driven failures; compliance expects explicit null checks and semantically appropriate exceptions.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/repository/impl/OcppServerRepositoryImpl.java[216-221]

Suggestion 12: [security] `messageId` hashed without check
`messageId` hashed without check The new fingerprinting logic hashes `messageId` without validating it for null/emptiness, which can cause NullPointerException or allow unexpected input sizes to impact processing. This violates input validation and null-safety requirements for externally supplied data.

Issue description

IncomingMessageIdStore.registerIncomingCallId() hashes messageId without validating it. Because messageId originates from an inbound OCPP message, this can cause null-driven failures (NPE) and lacks basic input validation.

Issue Context

This path is invoked for inbound CALL messages to enforce uniqueness. It must be resilient to malformed or missing message IDs and should fail gracefully with a protocol-appropriate error.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/ocpp/ws/IncomingMessageIdStore.java[74-83]
  • src/main/java/de/rwth/idsg/steve/ocpp/ws/pipeline/Deserializer.java[101-109]

Suggestion 13: [reliability] ID lists allow nulls
ID lists allow nulls • The `is*Set()` checks only verify that lists are non-empty, but do not ensure the lists contain at least one non-null (and valid) element. • Bean Validation annotations like `@Positive` do not reject `null`, so a request can produce lists such as `[null]` that pass `is*Set()` and then get used in `.in(...)` conditions. • This is an unhandled edge case that can lead to unexpected query behavior (e.g., `IN (NULL)` matching nothing) and makes failures harder to diagnose.

Issue description

List-based query parameters can be non-empty but still contain only null/invalid elements (e.g., [null]). Current is*Set() implementations only check non-emptiness and repository code passes these lists directly into .in(...) conditions.

Issue Context

@Positive does not reject null values, and CollectionUtils.isEmpty(...) only checks size. This combination can let invalid list contents through as 'set' and produce unexpected SQL conditions.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/web/dto/QueryForm.java[41-63]
  • src/main/java/de/rwth/idsg/steve/web/dto/ReservationQueryForm.java[42-70]
  • src/main/java/de/rwth/idsg/steve/web/dto/TransactionQueryForm.java[41-66]
  • src/main/java/de/rwth/idsg/steve/repository/impl/TransactionRepositoryImpl.java[280-301]
  • src/main/java/de/rwth/idsg/steve/repository/impl/ReservationRepositoryImpl.java[83-102]

Suggestion 14:

Add null check for filename

Add a null check for the result of file.getOriginalFilename() before calling .endsWith() to prevent a potential NullPointerException.

src/main/java/de/rwth/idsg/steve/web/controller/AboutSettingsController.java [144-156]

 @RequestMapping(value = ABOUT_PATH + "/import", method = RequestMethod.POST)
 public String importZip(@RequestParam("file") MultipartFile file, Model model) throws IOException {
     if (file.isEmpty()) {
         throw new SteveException.BadRequest("File is empty");
     }
 
-    if (!file.getOriginalFilename().endsWith(".zip")) {
+    String originalFilename = file.getOriginalFilename();
+    if (originalFilename == null || !originalFilename.endsWith(".zip")) {
         throw new SteveException.BadRequest("File must be a ZIP archive");
     }
 
     dataImportExportService.importZip(file.getInputStream());
     return "redirect:/manager/home";
 }

Pattern 2: Ensure collection-based deduplication and single-target selection are deterministic and keyed to the actual domain identity (e.g., chargeBoxId), and enforce “exactly one unique target” for single-station operations.

Example code before:

Set<ChargePointSelect> set = new HashSet<>(incoming); // equality may include extra fields
List<ChargePointSelect> list = new ArrayList<>(set);
ChargePointSelect first = list.get(0); // arbitrary if size > 1

Example code after:

Map<String, ChargePointSelect> byId = new LinkedHashMap<>();
for (ChargePointSelect s : incoming) {
    byId.putIfAbsent(s.getChargeBoxId(), s);
}
List<ChargePointSelect> list = new ArrayList<>(byId.values());

if (list.size() != 1) {
    throw new SteveException.BadRequest("Exactly one unique chargeBoxId must be provided");
}
ChargePointSelect only = list.get(0);
Relevant past accepted suggestions:
Suggestion 1: [correctness] Duplicate station IDs remain
Duplicate station IDs remain prepareAndCheck deduplicates ChargePointSelect via HashSet, but ChargePointSelect now uses @EqualsAndHashCode over ocppProtocol+chargeBoxId+endpointAddress, so the same chargeBoxId can still appear multiple times when other fields differ. This can trigger duplicate OCPP calls and causes CommunicationTask to overwrite per-chargeBoxId results while still counting duplicates in resultSize.

Issue description

prepareAndCheck() intends to deduplicate stations, but HashSet<ChargePointSelect> relies on ChargePointSelect.equals/hashCode. With Lombok's default @EqualsAndHashCode, equality includes ocppProtocol and endpointAddress, so multiple entries with the same chargeBoxId can survive. Downstream CommunicationTask uses chargeBoxId as the key, so duplicates overwrite results and can cause duplicate side effects.

Issue Context

This PR introduced @EqualsAndHashCode on ChargePointSelect and uses a HashSet to deduplicate ChargePointSelect entries.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/repository/dto/ChargePointSelect.java[31-43]
  • src/main/java/de/rwth/idsg/steve/service/ChargePointServiceClient.java[147-176]

Suggested fix

  • Make deduplication explicitly keyed by chargeBoxId (e.g., build a LinkedHashMap<String, ChargePointSelect>), and decide which entry to keep when duplicates exist (e.g., prefer one with isEndpointAddressSet() or prefer JSON-connected one depending on your intended semantics).
  • Alternatively (and only if correct globally), change Lombok to @EqualsAndHashCode(of = "chargeBoxId") so set-based dedup matches the station identity used elsewhere.

Suggestion 2: [correctness] Nondeterministic single-station target
Nondeterministic single-station target prepareAndCheck converts a HashSet to an ArrayList, producing an undefined order; single-station operations then use BackgroundService.forFirst(list).get(0), which can select an arbitrary station if more than one entry ends up in the merged list. This makes operations like remoteStartTransaction/reserveNow potentially act on the wrong station when inputs contain multiple stations (e.g., via mixed chargeBoxIdList and chargePointSelectList).

Issue description

prepareAndCheck() builds the final station list from a HashSet and assigns it to params. For single-station operations, the code later picks list.get(0) (via BackgroundService.forFirst), so if the final list contains >1 station, the chosen target becomes nondeterministic.

Issue Context

The PR changed list construction to new ArrayList<>(stationSet), where stationSet is a HashSet.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/service/ChargePointServiceClient.java[147-176]
  • src/main/java/de/rwth/idsg/steve/service/BackgroundService.java[42-47]

Suggested fix

  • Preserve a deterministic order when building the final list (e.g., use LinkedHashSet/LinkedHashMap to preserve insertion order, or sort by chargeBoxId).
  • For params types representing single-station operations (SingleChargePointSelect subclasses), assert exactly one unique chargeBoxId after merging/dedup; otherwise throw SteveException.BadRequest with a clear message.

Suggestion 3: [reliability] ID lists allow nulls
ID lists allow nulls • The `is*Set()` checks only verify that lists are non-empty, but do not ensure the lists contain at least one non-null (and valid) element. • Bean Validation annotations like `@Positive` do not reject `null`, so a request can produce lists such as `[null]` that pass `is*Set()` and then get used in `.in(...)` conditions. • This is an unhandled edge case that can lead to unexpected query behavior (e.g., `IN (NULL)` matching nothing) and makes failures harder to diagnose.

Issue description

List-based query parameters can be non-empty but still contain only null/invalid elements (e.g., [null]). Current is*Set() implementations only check non-emptiness and repository code passes these lists directly into .in(...) conditions.

Issue Context

@Positive does not reject null values, and CollectionUtils.isEmpty(...) only checks size. This combination can let invalid list contents through as 'set' and produce unexpected SQL conditions.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/web/dto/QueryForm.java[41-63]
  • src/main/java/de/rwth/idsg/steve/web/dto/ReservationQueryForm.java[42-70]
  • src/main/java/de/rwth/idsg/steve/web/dto/TransactionQueryForm.java[41-66]
  • src/main/java/de/rwth/idsg/steve/repository/impl/TransactionRepositoryImpl.java[280-301]
  • src/main/java/de/rwth/idsg/steve/repository/impl/ReservationRepositoryImpl.java[83-102]

Suggestion 4:

Handle multiple active transactions gracefully

Handle cases with multiple active transactions by returning the most recent one instead of throwing an IllegalStateException.

src/main/java/de/rwth/idsg/steve/service/TransactionService.java [83-98]

 public Transaction getActiveTransaction(String chargeBoxId, Integer connectorId) {
     TransactionQueryForm form = new TransactionQueryForm();
     form.setChargeBoxId(chargeBoxId);
     form.setConnectorId(connectorId);
     form.setType(TransactionQueryForm.QueryType.ACTIVE);
 
     List<Transaction> transactions = transactionRepository.getTransactions(form);
     if (transactions.isEmpty()) {
         return null;
-    } else if (transactions.size() == 1) {
+    } else if (transactions.size() > 1) {
+        log.warn("Found multiple active transactions for chargeBoxId '{}' and connectorId '{}'. Returning the most recent one.", chargeBoxId, connectorId);
+        return transactions.stream()
+                           .max(Comparator.comparing(Transaction::getStartTimestamp))
+                           .orElse(null); // Should not be null here, but for safety
+    } else {
         return transactions.get(0);
-    } else {
-        throw new IllegalStateException("There are multiple active transactions with the same charge box id and connector id");
     }
 }

Pattern 3: Use correct exception types and preserve diagnostic causes while avoiding leakage of sensitive/internal details in client-visible errors (e.g., map not-found to 404, chain causes, don’t echo raw payloads).

Example code before:

try {
    ...
} catch (Exception e) {
    throw new SteveException(e.getMessage()); // loses cause
}

if (entity == null) {
    throw new SteveException("Missing entity id=" + id); // maps to 500
}

throw new IllegalArgumentException("Bad payload: " + rawMessage); // leaks raw input

Example code after:

try {
    ...
} catch (Exception e) {
    throw new SteveException("Send failed for chargeBoxId=" + chargeBoxId, e);
}

if (entity == null) {
    throw new SteveException.NotFound("Entity not found");
}

// Client-safe message; raw payload only in internal debug logs (optionally truncated/redacted)
throw new SteveException.BadRequest("Invalid request payload");
Relevant past accepted suggestions:
Suggestion 1: [security] `insertIgnoreConnectorInternal` uses generic exception
`insertIgnoreConnectorInternal` uses generic exception When the charge box is not found, the new code throws a generic `SteveException` with an internal identifier in the message, which may map to an incorrect client error and can leak internal details.

Issue description

A not-found condition for chargeBoxIdentity throws a generic SteveException and includes the identifier in the exception message. This can produce incorrect error semantics for clients and may leak internal details.

Issue Context

Per compliance, not-found conditions should map to a not-found/404-style exception, and client-visible messages should be generic (detailed context should be logged internally).

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/repository/impl/OcppServerRepositoryImpl.java[458-460]

Suggestion 2: [observability] `validateCSR()` drops exception cause
`validateCSR()` drops exception cause `CertificateSigningServiceLocal.validateCSR` catches parsing/validation exceptions and rethrows new `IllegalArgumentException`s without chaining the original cause. This loses root-cause context and hinders diagnostics.

Issue description

Exceptions caught during CSR parsing/validation are rethrown without the original cause, losing diagnostic context.

Issue Context

These failures are common/expected validation paths; preserving causes enables debugging without adding noisy logs.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/service/CertificateSigningServiceLocal.java[131-142]

Suggestion 3: [correctness] Missing tx returns 500
Missing tx returns 500 GET /api/v1/transactions/{transactionPk} and PATCH /{transactionPk}/stop propagate a generic SteveException when the transactionPk doesn’t exist, which ApiControllerAdvice maps to HTTP 500 instead of 404. This breaks API semantics and makes client-side error handling/monitoring inaccurate.

Issue description

The new transaction APIs return HTTP 500 when transactionPk does not exist because the code throws a generic SteveException instead of SteveException.NotFound, so ApiControllerAdvice maps it to the generic 500 handler.

Issue Context

ApiControllerAdvice maps SteveException.NotFound to 404, but TransactionRepositoryImpl.getDetails() throws new SteveException("There is no transaction...") when the ID is missing.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/repository/impl/TransactionRepositoryImpl.java[165-168]
  • src/main/java/de/rwth/idsg/steve/web/api/TransactionsRestController.java[99-110]

Suggested fix

  • Change the thrown exception for the missing-transaction case to throw new SteveException.NotFound("There is no transaction with id ...") (or perform the mapping in TransactionService/controller).
  • (Optional but recommended) Add controller tests asserting 404 for unknown transactionPk on both the details and stop endpoints.

Suggestion 4: [reliability] `SteveException` drops cause
`SteveException` drops cause `Sender.accept()` throws `new SteveException(e.getMessage())` for outgoing CALL failures, losing the original exception cause/stack trace and omitting useful context (e.g., chargeBoxId/messageId). This reduces debuggability and violates the requirement for meaningful, contextual error handling.

Issue description

Sender.accept() throws new SteveException(e.getMessage()) which drops the original exception cause/stack trace and provides little operational context.

Issue Context

This code path handles outbound OCPP CALL sends; when sending fails, troubleshooting needs the underlying cause plus identifiers like chargeBoxId, session.getId(), and/or messageId.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/ocpp/ws/pipeline/Sender.java[59-68]

Suggestion 5: [security] Exception exposes raw message
Exception exposes raw message `Deserializer.validate()` embeds the full inbound websocket payload (`cc.getIncomingString()`) in thrown exceptions, which can leak sensitive/internal details if the message is surfaced beyond secure internal logs. This violates secure error handling expectations for user/peer-visible error messages.

Issue description

Deserializer.validate() includes the full inbound message payload in exception messages, which risks leaking sensitive/internal data.

Issue Context

These exceptions may be logged or propagated; secure error handling requires generic external-facing messages and keeping detailed payloads only in secure internal logs (preferably redacted/truncated).

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/ocpp/ws/pipeline/Deserializer.java[233-240]

Suggestion 6: [security] `e.getOriginalMessage()` exposed externally
`e.getOriginalMessage()` exposed externally The PR now forwards `DatabindException.getOriginalMessage()` into `OcppJsonError.errorDetails`, which is sent back to the caller. This can expose internal implementation details (e.g., class names/enum values) to clients, violating secure error handling expectations.

Issue description

DatabindException.getOriginalMessage() is forwarded into the outbound OCPP error payload (errorDetails), which may expose internal class/type information to clients.

Issue Context

ErrorFactory.propertyConstraintViolation(..., details) stores details into OcppJsonError.errorDetails, which is part of the response sent to the caller. The PR changed the details source to e.getOriginalMessage().

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/ocpp/ws/pipeline/Deserializer.java[132-135]

Suggestion 7:

Improve exception handling for validation

In verifyOcppFields, replace NullPointerException with IllegalArgumentException when cpoName or serialNumber are missing to improve error handling consistency and robustness.

src/main/java/de/rwth/idsg/steve/service/CertificateValidator.java [118-148]

 public void verifyOcppFields(ChargePointRegistration chargePointRegistration, X500Name subject) {
     // A00.FR.404 :
     // The Central System SHALL verify that the certificate is owned by the CPO (or an
     // organization trusted by the CPO) by checking that the O (organizationName) RDN
     // in the subject field of the certificate contains the CPO name.
     {
         String organizationName = CertificateUtils.getOrganization(subject);
         String cpoName = chargePointRegistration.cpoName();
         if (StringUtils.isEmpty(cpoName)) {
-            throw new NullPointerException("CPO name is not set for chargeBoxId: " + chargePointRegistration.chargeBoxId());
+            throw new IllegalArgumentException("CPO name is not set for chargeBoxId: " + chargePointRegistration.chargeBoxId());
         }
         if (!cpoName.equals(organizationName)) {
             throw new IllegalArgumentException("Validation failed: organizationName is not equal to CPO name");
         }
     }
 
     // A00.FR.405 :
     // The Central System SHALL verify that the certificate belongs to this Charge Point by
     // checking that the CN (commonName) RDN in the subject field of the certificate
     // contains the unique Serial Number of the Charge Point
     {
         String commonName = CertificateUtils.getCommonName(subject);
         String serialNumber = chargePointRegistration.serialNumber();
         if (StringUtils.isEmpty(serialNumber)) {
-            throw new NullPointerException("Serial number is not set for chargeBoxId: " + chargePointRegistration.chargeBoxId());
+            throw new IllegalArgumentException("Serial number is not set for chargeBoxId: " + chargePointRegistration.chargeBoxId());
         }
         if (!serialNumber.equals(commonName)) {
             throw new IllegalArgumentException("Validation failed: commonName is not equal to Serial Number of the Charge Point");
         }
     }
 }

Pattern 4: Improve operational logs by including key identifiers (chargeBoxId, connectorId, config key, messageId) and avoid stack traces for expected validation failures; reserve stack traces for unexpected/internal errors.

Example code before:

log.warn("Validation failed", e); // noisy stacktrace, missing context
log.error("Async processing failed", e); // cannot attribute to station

Example code after:

log.warn("Validation failed: {} (chargeBoxId={}, connectorId={})",
         e.getMessage(), chargeBoxId, connectorId);

log.error("Async processing failed for chargeBoxId='{}'", chargeBoxId, e); // stacktrace ok if unexpected
Relevant past accepted suggestions:
Suggestion 1: [observability] Error logs missing identifiers
Error logs missing identifiers New error logs omit key identifiers (e.g., `chargeBoxId` and config `key`), reducing diagnostic value and making incidents harder to trace. This violates the requirement to keep logs high-signal with relevant context when handling errors.

Issue description

New error logs for OCPP configuration updates are missing relevant identifiers (chargeBoxId, and key where applicable), reducing log signal and making debugging difficult.

Issue Context

These paths run on station connect / configuration changes; failures should be attributable to a specific station and config key.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/service/ChargePointService.java[123-140]
  • src/main/java/de/rwth/idsg/steve/ocpp/task/GetConfigurationTask.java[133-136]

Suggestion 2: [observability] Async log misses chargeBoxId
Async log misses chargeBoxId CertificateSigningServiceLocal.processAndSendToStation logs an error without including chargeBoxId, so failures in the scheduled CSR processing cannot be reliably attributed to a specific station. This is especially problematic because this method runs asynchronously after SignCertificate has already been accepted.

Issue description

Async CSR processing errors are logged without the station identifier, making incident triage and support difficult.

Issue Context

The CSR processing is explicitly delayed/scheduled asynchronously. When it fails, the log message should identify which charge point triggered it.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/service/CertificateSigningServiceLocal.java[147-154]

Suggested change

Change the catch block to include context, e.g.:

  • log.error("CSR processing failed for chargeBoxId='{}'", chargeBoxId, e);

Suggestion 3: [observability] Handshake auth logs stacktrace
Handshake auth logs stacktrace The handshake path logs a full exception stack trace when authentication extraction fails, which is a common/expected validation failure path and creates noisy logs. This violates the requirement to keep logs high-signal and avoid stack traces for expected validation failures.

Issue description

Auth extraction failures during WebSocket handshake are handled as BAD_REQUEST, but the code logs the exception with a full stack trace, creating noise for expected/normal validation failures.

Issue Context

Compliance requires high-signal logs: log expected validation failures without stack traces (optionally log the stack trace at DEBUG).

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/ocpp/ws/OcppWebSocketHandshakeHandler.java[123-129]

Suggestion 4: [observability] Connector mismatch misreported
Connector mismatch misreported In meterValues(), a connectorId mismatch causes getTransaction(chargeBoxId, connectorId, txId) to return null and the validator reports “transaction not found”, obscuring the real issue (wrong connectorId for an existing transactionId) while the request is still persisted using that transactionId.

Issue description

meterValues() treats a connectorId mismatch as a missing transaction because getTransaction(chargeBoxId, connectorId, txId) returns null when the connector does not match, and the validator turns null into a generic “transaction not found” error.

Issue Context

This makes debugging harder because the transaction may exist but be associated to a different connector.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/service/CentralSystemService16_Service.java[194-214]
  • src/main/java/de/rwth/idsg/steve/repository/impl/OcppServerRepositoryImpl.java[210-236]
  • src/main/java/de/rwth/idsg/steve/service/CentralSystemService16_ServiceValidator.java[129-150]

Suggested fix

  • If getTransaction(chargeBoxId, connectorId, txId) returns null, optionally do a second lookup without connectorId to distinguish:
  • tx truly missing vs
  • tx exists but connector mismatch.
  • Return/log a specific message for connector mismatch (and include connectorId in repository warning logs).

Suggestion 5: [correctness] Noisy validation stacktraces
Noisy validation stacktraces On StatusNotification validation failure, the code logs the SteveException as a Throwable, which emits a full stack trace for an expected invalid-input path. This increases log noise and makes real errors harder to spot.

Issue description

Validation failures are logged with a Throwable, producing stack traces for expected invalid-input cases.

Issue Context

SteveException is used here as a validation result object; stack traces add noise without improving debuggability.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/service/CentralSystemService16_Service.java[151-154]
  • src/main/java/de/rwth/idsg/steve/SteveException.java[27-38]

Implementation notes

  • Prefer logging only the message and relevant fields, e.g.:
  • log.warn("StatusNotification validation failed: {} (chargeBoxId={}, connectorId={})", exception.getMessage(), chargeBoxIdentity, parameters.getConnectorId());
  • If you still want an exception type for validation, consider a lightweight type or overriding fillInStackTrace() for validation-only exceptions (but keep scope limited to avoid side effects elsewhere).

Suggestion 6: [correctness] Logs wrong exception
Logs wrong exception When persisting a failed-stop record itself fails, the code logs the *original* exception instead of the insertion exception, hiding the real root cause. This significantly reduces debuggability for production failures in the StopTransaction path.

Issue description

updateTransactionAsFailed catches exceptions as ex but logs e, masking the actual failure when inserting into TRANSACTION_STOP_FAILED fails.

Issue Context

This code path is used both when updateTransaction fails and when StopTransaction validation fails; correct logging is critical for diagnosis.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/repository/impl/OcppServerRepositoryImpl.java[325-328]

Pattern 5: Make concurrency and asynchronous code paths explicit and safe: use thread-safe collections/defensive copies across threads, handle await timeouts, and avoid state corruption from concurrent modification or ignored events.

Example code before:

Deque<Item> q = new ArrayDeque<>(); // shared across threads

latch.await(5, TimeUnit.SECONDS); // return value ignored

for (Session s : sessions) {
    s.close(); // may modify 'sessions' via callbacks -> CME
}

Example code after:

Queue<Item> q = new ConcurrentLinkedQueue<>();

boolean ok = latch.await(5, TimeUnit.SECONDS);
if (!ok) {
    throw new AssertionError("Timed out waiting for expected messages");
}

for (Session s : new ArrayList<>(sessions)) {
    s.close();
}
Relevant past accepted suggestions:
Suggestion 1: [reliability] Fixed sleep causes flakes
Fixed sleep causes flakes The TLS certification IT uses a fixed 1-second sleep before asserting DB state, which can intermittently fail under slow CI/DB load because persistence timing is nondeterministic.

Issue description

A fixed Thread.sleep(1s) is used to wait for DB updates, which can be too short on CI and cause flaky failures.

Issue Context

The certificate signing + persistence is asynchronous relative to the test flow.

Fix Focus Areas

  • src/test/java/de/rwth/idsg/steve/certification/ocpp16/Ocpp16JsonCsmsCertification_TLS_IT.java[161-189]

Suggested fix

  • Replace Thread.sleep with polling + timeout (e.g., Awaitility or a simple loop with Thread.sleep(100ms) and a max duration) until the expected DB row/flag appears.
  • Assert with a clear timeout failure message showing current DB state.

Suggestion 2: [reliability] `exchangeQueue` uses `ArrayDeque`
`exchangeQueue` uses `ArrayDeque` `exchangeQueue` is a non-thread-safe `ArrayDeque` but is written from the test thread (`sendInternal`/`expectRequestInternal`) and read from the WebSocket callback thread (`onMessage` handlers). This can cause race conditions (lost/misordered exchanges) and flaky tests under concurrent access.

Issue description

exchangeQueue is implemented with ArrayDeque, but it is used as a shared mutable queue across threads (test thread enqueues; WebSocket callback thread dequeues). This can lead to race conditions and flaky behavior.

Issue Context

This helper uses Jetty WebSocket callbacks (@OnWebSocketMessage) which typically run on a different thread than the caller of send(...)/expectRequest(...).

Fix Focus Areas

  • src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[89-112]
  • src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[218-218]
  • src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[321-323]
  • src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[367-370]
  • src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[396-399]

Suggestion 3: [reliability] Latch timeouts ignored
Latch timeouts ignored sendInternal() and close() call CountDownLatch.await(timeout) but ignore the boolean return, so timeouts can silently turn into null incoming messages, misleading assertion failures, or leaked websocket/client resources.

Issue description

CountDownLatch.await(timeout, unit) returns false on timeout, but the code ignores this and continues. This can mask real websocket/protocol failures and produce confusing downstream errors (e.g., null incoming message).

Issue Context

This utility is used by multiple tests (and the stress tester). When communication fails or a callback is disrupted, you want an immediate, descriptive failure rather than a silent timeout that turns into a generic assertion.

Fix Focus Areas

  • src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[142-152]
  • src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[222-239]

Suggested fix

  • Capture the boolean return of await(...) and throw an AssertionFailedError (or RuntimeException) when it is false, including context (action/messageId/expected type).
  • For close(), if the await times out, attempt client.stop()/client.close() and throw, so tests don’t leak threads/sessions.
  • Optionally also add a timeout to connect.get(...) in start() for symmetry.

Suggestion 4: [correctness] Unexpected msg satisfies latch
Unexpected msg satisfies latch Unexpected incoming CALLs are now ignored but still decrement the CountDownLatch in onMessage(), so process() can return even while responseContextMap still contains pending expected responses. This can make tests falsely pass or become flaky when extra backend messages arrive during a run.

Issue description

process() uses a CountDownLatch sized to the number of expected messages, but onMessage() currently decrements the latch for every received message. After this PR, unexpected CALLs are no longer failing fast and will incorrectly consume latch counts, allowing process() to return early while expected responses are still missing.

Issue Context

This is introduced by switching unexpected CALL handling from interrupt-based failure to returning an OcppJsonCallToIgnore. The latch logic wasn’t updated accordingly.

Fix Focus Areas

  • src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[128-152]
  • src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[212-239]
  • src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[377-407]

Suggested fix approach

  • Only call receivedMessagesSignal.countDown() when the incoming message matches an expected item:
  • For CALL_RESULT / CALL_ERROR: only if responseContextMap.remove(messageId) returned non-null.
  • For CALLs: only if the action exists in requestContextMap (and ideally also matches payload) and you actually handled it.
  • For ignored/unexpected messages: do not decrement the latch.
  • Add a post-await sanity check in process() (e.g., fail if responseContextMap or requestContextMap is not empty) to prevent false-positive passes.

Suggestion 5: [reliability] Ignored CALL never replied
Ignored CALL never replied When an unexpected CALL arrives, the simulator only logs a warning and sends no CALL_RESULT/CALL_ERROR, unlike the normal incoming CALL path which replies via session.sendText(). This can leave the backend waiting on a response and cause the test run to stall because process() awaits without a timeout.

Issue description

Unexpected incoming OCPP CALLs are now ignored without any reply, which is inconsistent with how incoming CALLs are normally handled (they get a CALL_RESULT response). This can stall the backend side and hang tests because process() awaits without a timeout.

Issue Context

The current ignore path loses important info (messageId/action) and does not send anything back.

Fix Focus Areas

  • src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[135-144]
  • src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[377-407]
  • src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[408-421]

Suggested fix approach

  • Change OcppJsonCallToIgnore to carry at least messageId and action (and optionally the payload string).
  • In onMessage() for OcppJsonCallToIgnore, send a CALL_ERROR (e.g., ErrorCode.NotImplemented or NotSupported) for that messageId so the backend can unblock.
  • You can mirror the existing CALL_RESULT creation logic in handleCall(...), but use MessageType.CALL_ERROR.getTypeNr() and the error fields.
  • Consider adding a bounded await (timeout) in process() to prevent infinite hangs even if the backend misbehaves.

Suggestion 6: [reliability] FK checks leak
FK checks leak DataImportExportRepositoryImplIT.beforeImport() disables MySQL/MariaDB foreign key checks but does not restore them in the same test, so pooled JDBC connections can be reused with FK checks still disabled and affect later tests. This can make tests order-dependent and hide FK violations.

Issue description

DataImportExportRepositoryImplIT.beforeImport() disables foreign_key_checks but the test method never restores it, and afterImport() is a separate test. Because connections are pooled, a later test may run on a connection with FK checks still disabled.

Issue Context

  • DataImportExportRepositoryImpl.beforeImport() executes set foreign_key_checks=0 and afterImport() executes set foreign_key_checks=1.
  • Hikari pooling is used, so session variables can persist on a reused connection.

Fix Focus Areas

  • src/test/java/de/rwth/idsg/steve/repository/impl/DataImportExportRepositoryImplIT.java[55-71]

Suggested fix

  • Remove the standalone beforeImport() / afterImport() tests, or combine them into a single test that uses try { beforeImport(); ... } finally { afterImport(); }.
  • If you want to keep a standalone test for beforeImport(), add an @AfterEach that calls repository.afterImport() to guarantee restoration even on failures.

Suggestion 7: [correctness] Incorrect connect notifications
Incorrect connect notifications • SessionContextStoreImpl.add() now short-circuits when `session.isOpen()` is false and returns the *existing* deque size without adding. • AbstractWebSocketEndpoint.onOpen() interprets `sizeAfterAdd == 1` as “transition 0→1” and publishes a connected event; if there is already 1 active session and a second `onOpen` arrives with a non-open session, add() returns 1 and causes a spurious connected notification. • onOpen() also calls `futureResponseContextStore.addSession(session)` before add(); if add() no-ops, the future-response store can be populated for a session that was never registered in SessionContextStore (inconsistent state).

Issue description

SessionContextStoreImpl.add() can no-op when session.isOpen() is false but still returns a size value that AbstractWebSocketEndpoint.onOpen() uses to decide whether to publish the “connected” event (sizeAfterAdd == 1). This can emit spurious connected notifications and also leaves FutureResponseContextStore populated for a session that was never stored in SessionContextStore.

Issue Context

onOpen() performs side effects (protocol update + futureResponseContextStore.addSession) before calling sessionContextStore.add(). The store now contains defensive logic that may reject the add.

Fix Focus Areas

  • src/main/java/de/rwth/idsg/steve/ocpp/ws/SessionContextStoreImpl.java[66-95]
  • src/main/java/de/rwth/idsg/steve/ocpp/ws/AbstractWebSocketEndpoint.java[134-150]
  • src/main/java/de/rwth/idsg/steve/ocpp/ws/SessionContextStore.java[35-39]

Suggestion 8:

Return unmodifiable deques to prevent mutation

The getACopy() method should return a map with unmodifiable deques to prevent callers from mutating the internal state and bypassing the locking mechanism. This can be achieved by wrapping the deques using Collections.unmodifiableDeque().

Examples:

src/main/java/de/rwth/idsg/steve/ocpp/ws/SessionContextStoreImpl.java [184-187]

    public Map> getACopy() {
        // we just want an immutable view of the map without copying the underlying data
        return Collections.unmodifiableMap(lookupTable);
    }

Solution Walkthrough:

Before:

// in SessionContextStoreImpl.java
public class SessionContextStoreImpl implements SessionContextStore {
    private final ConcurrentHashMap> lookupTable = new ConcurrentHashMap<>();
    // ...
    @Override
    public Map> getACopy() {
        // The map is unmodifiable, but its values (Deques) are mutable.
        // This allows callers to modify the internal state without acquiring locks.
        return Collections.unmodifiableMap(lookupTable);
    }
}

After:

// in SessionContextStoreImpl.java
public class SessionContextStoreImpl implements SessionContextStore {
    private final ConcurrentHashMap> lookupTable = new ConcurrentHashMap<>();
    // ...
    @Override
    public Map> getACopy() {
        // Create a new map where each deque is wrapped to be unmodifiable.
        // This prevents mutation of the internal state by callers.
        return lookupTable.entrySet().stream()
            .collect(Collectors.toUnmodifiableMap(
                Map.Entry::getKey,
                e -> Collections.unmodifiableDeque(e.getValue())
            ));
    }
}

Suggestion 9:

Create defensive copy before iteration

Create a defensive copy of the logger list before iterating, as the collection may be modified during iteration by logging framework operations. Use new ArrayList<>(context.getLoggerList()) to prevent potential ConcurrentModificationException.

src/main/java/de/rwth/idsg/steve/SteveApplicationStartupListener.java [54-62]

-context.getLoggerList().forEach(logger -> {
+new ArrayList<>(context.getLoggerList()).forEach(logger -> {
     logger.iteratorForAppenders().forEachRemaining(appender -> {
         if (appender instanceof FileAppender<?> fileAppender) {
             String file = fileAppender.getFile();
             System.out.println("------------------- Starting -------------------");
             System.out.println("Log file location: " + file);
         }
     });
 });

Suggestion 10:

Avoid concurrent modification during iteration

To prevent a ConcurrentModificationException, iterate over a copy of endpointDeque when closing sessions. The close() operation can trigger an event that modifies the deque, causing an error.

src/main/java/de/rwth/idsg/steve/ocpp/ws/SessionContextStoreImpl.java [145-151]

-for (SessionContext sessionContext : endpointDeque) {
+for (SessionContext sessionContext : new ArrayDeque<>(endpointDeque)) {
     try {
         sessionContext.getSession().close();
     } catch (IOException e) {
         log.error("Error while closing web socket session for chargeBoxId '{}'", chargeBoxId, e);
     }
 }

Suggestion 11:

Make store holder thread-safe

**

  • @author Sevket Goekay sevketgokay@gmail.com @@ -32,7 +32,7 @@ @Component public class SessionContextStoreHolder {
  • private final EnumMap<OcppVersion, SessionContextStore> storesPerVersion = new EnumMap<>(OcppVersion.class);
  • private final ConcurrentHashMap<OcppVersion, SessionContextStore> storesPerVersion = new ConcurrentHashMap<>();






**Replace the non-thread-safe EnumMap in SessionContextStoreHolder with a java.util.concurrent.ConcurrentHashMap. This prevents race conditions when getOrCreate is called concurrently from multiple threads.**

[src/main/java/de/rwth/idsg/steve/ocpp/ws/SessionContextStoreHolder.java [32-46]](https://github.com/steve-community/steve/pull/1865/files#diff-7f2ea532644135afba5b5cf2e742659effaa151c398f1b3891320538d15a2d35R32-R46)

```diff
 @Component
 public class SessionContextStoreHolder {
 
-    private final EnumMap<OcppVersion, SessionContextStore> storesPerVersion = new EnumMap<>(OcppVersion.class);
+    private final java.util.concurrent.ConcurrentMap<OcppVersion, SessionContextStore> storesPerVersion =
+            new java.util.concurrent.ConcurrentHashMap<>();
 
     private final WsSessionSelectStrategy wsSessionSelectStrategy;
 
     public SessionContextStoreHolder(SteveProperties steveProperties) {
         wsSessionSelectStrategy = steveProperties.getOcpp().getWsSessionSelectStrategy();
     }
 
     public SessionContextStore getOrCreate(OcppVersion version) {
-        return storesPerVersion.computeIfAbsent(version, k -> new SessionContextStoreImpl(wsSessionSelectStrategy));
+        return storesPerVersion.computeIfAbsent(version, v -> new SessionContextStoreImpl(wsSessionSelectStrategy));
     }
 }

[Auto-generated best practices - 2026-06-19]

Clone this wiki locally