-
-
Notifications
You must be signed in to change notification settings - Fork 485
.pr_agent_auto_best_practices
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.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.
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.
- 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.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.
The compliance requirement expects fail-fast validation (e.g., IllegalArgument/BadRequest-style) before dereferencing/using inputs in SQL predicate construction.
- 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.Helpers.getJsonPath(ServerProperties) calls serverProperties.getAddress().toString() without checking for null, which can cause NPE when address is not configured.
ServerProperties values come from external configuration/defaults; address may be unset.
- 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.normalizePemInput dereferences pem without a null check, risking NPE on missing PEM inputs.
PEM inputs can be externally supplied (e.g., headers/config). Compliance requires validating before dereferencing.
- 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.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.
idTagFromTransaction originates from transaction input (p.getIdTag()), i.e., external/request-derived data.
- 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.updateSecurityProfile(chargeBoxId, securityProfile) does not validate securityProfile and can throw parsing exceptions (NumberFormatException/IllegalArgumentException) instead of a clear client-facing validation error.
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.
- 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.validateSecurityEvent(...) uses params.getTimestamp().getMillis() without null/presence checks, risking NPE and unpredictable behavior for invalid external inputs.
Other validators in this class (e.g., validateStatusNotification) check timestamp presence (isSetTimestamp()) before using it; this method should follow the same pattern.
- 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.validateStatusNotification(StatusNotificationRequest params) does not validate params for null before dereferencing it, risking an NPE and unclear failure behavior.
StatusNotificationRequest is externally supplied and should be validated/normalized before comparisons or dereferences.
- 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.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.
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.
- 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]
- Enforce non-null with explicit validation (e.g.,
Objects.requireNonNullor throwSteveException.BadRequest("'from' must be provided"), same forto). - Keep the existing ordering check, but run it only after non-null validation.
- Optionally, make
toDateTimethrow 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.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.
Compliance requires sanitizing externally supplied collections (treat null as empty, filter null elements) and failing fast with clear/semantically correct validation errors.
- 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.chargeBoxId is a new input to getTransaction(...) but is not explicitly validated for null (and potentially blank) before being used in the query predicate.
Null/blank chargeBoxId can lead to incorrect query behavior and null-driven failures; compliance expects explicit null checks and semantically appropriate exceptions.
- 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.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.
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.
- 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.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.
@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.
- 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.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.
This PR introduced @EqualsAndHashCode on ChargePointSelect and uses a HashSet to deduplicate ChargePointSelect entries.
- 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]
- Make deduplication explicitly keyed by
chargeBoxId(e.g., build aLinkedHashMap<String, ChargePointSelect>), and decide which entry to keep when duplicates exist (e.g., prefer one withisEndpointAddressSet()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).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.
The PR changed list construction to new ArrayList<>(stationSet), where stationSet is a HashSet.
- src/main/java/de/rwth/idsg/steve/service/ChargePointServiceClient.java[147-176]
- src/main/java/de/rwth/idsg/steve/service/BackgroundService.java[42-47]
- Preserve a deterministic order when building the final list (e.g., use
LinkedHashSet/LinkedHashMapto preserve insertion order, or sort bychargeBoxId). - For params types representing single-station operations (
SingleChargePointSelectsubclasses), assert exactly one uniquechargeBoxIdafter merging/dedup; otherwise throwSteveException.BadRequestwith 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.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.
@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.
- 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.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.
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).
- 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.Exceptions caught during CSR parsing/validation are rethrown without the original cause, losing diagnostic context.
These failures are common/expected validation paths; preserving causes enables debugging without adding noisy logs.
- 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.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.
ApiControllerAdvice maps SteveException.NotFound to 404, but TransactionRepositoryImpl.getDetails() throws new SteveException("There is no transaction...") when the ID is missing.
- 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]
- Change the thrown exception for the missing-transaction case to
throw new SteveException.NotFound("There is no transaction with id ...")(or perform the mapping inTransactionService/controller). - (Optional but recommended) Add controller tests asserting 404 for unknown
transactionPkon 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.Sender.accept() throws new SteveException(e.getMessage()) which drops the original exception cause/stack trace and provides little operational 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.
- 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.Deserializer.validate() includes the full inbound message payload in exception messages, which risks leaking sensitive/internal data.
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).
- 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.DatabindException.getOriginalMessage() is forwarded into the outbound OCPP error payload (errorDetails), which may expose internal class/type information to clients.
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().
- 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.New error logs for OCPP configuration updates are missing relevant identifiers (chargeBoxId, and key where applicable), reducing log signal and making debugging difficult.
These paths run on station connect / configuration changes; failures should be attributable to a specific station and config key.
- 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.Async CSR processing errors are logged without the station identifier, making incident triage and support difficult.
The CSR processing is explicitly delayed/scheduled asynchronously. When it fails, the log message should identify which charge point triggered it.
- src/main/java/de/rwth/idsg/steve/service/CertificateSigningServiceLocal.java[147-154]
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.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.
Compliance requires high-signal logs: log expected validation failures without stack traces (optionally log the stack trace at DEBUG).
- 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.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.
This makes debugging harder because the transaction may exist but be associated to a different connector.
- 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]
- If
getTransaction(chargeBoxId, connectorId, txId)returns null, optionally do a second lookup withoutconnectorIdto 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.Validation failures are logged with a Throwable, producing stack traces for expected invalid-input cases.
SteveException is used here as a validation result object; stack traces add noise without improving debuggability.
- src/main/java/de/rwth/idsg/steve/service/CentralSystemService16_Service.java[151-154]
- src/main/java/de/rwth/idsg/steve/SteveException.java[27-38]
- 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.updateTransactionAsFailed catches exceptions as ex but logs e, masking the actual failure when inserting into TRANSACTION_STOP_FAILED fails.
This code path is used both when updateTransaction fails and when StopTransaction validation fails; correct logging is critical for diagnosis.
- 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.A fixed Thread.sleep(1s) is used to wait for DB updates, which can be too short on CI and cause flaky failures.
The certificate signing + persistence is asynchronous relative to the test flow.
- src/test/java/de/rwth/idsg/steve/certification/ocpp16/Ocpp16JsonCsmsCertification_TLS_IT.java[161-189]
- Replace
Thread.sleepwith polling + timeout (e.g., Awaitility or a simple loop withThread.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.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.
This helper uses Jetty WebSocket callbacks (@OnWebSocketMessage) which typically run on a different thread than the caller of send(...)/expectRequest(...).
- 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.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).
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.
- src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[142-152]
- src/test/java/de/rwth/idsg/steve/utils/OcppJsonChargePoint.java[222-239]
- Capture the boolean return of
await(...)and throw anAssertionFailedError(orRuntimeException) when it isfalse, including context (action/messageId/expected type). - For
close(), if the await times out, attemptclient.stop()/client.close()and throw, so tests don’t leak threads/sessions. - Optionally also add a timeout to
connect.get(...)instart()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.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.
This is introduced by switching unexpected CALL handling from interrupt-based failure to returning an OcppJsonCallToIgnore. The latch logic wasn’t updated accordingly.
- 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]
- 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 ifresponseContextMaporrequestContextMapis 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.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.
The current ignore path loses important info (messageId/action) and does not send anything back.
- 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]
- Change
OcppJsonCallToIgnoreto carry at leastmessageIdandaction(and optionally the payload string). - In
onMessage()forOcppJsonCallToIgnore, send aCALL_ERROR(e.g.,ErrorCode.NotImplementedorNotSupported) for thatmessageIdso the backend can unblock. - You can mirror the existing CALL_RESULT creation logic in
handleCall(...), but useMessageType.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.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.
-
DataImportExportRepositoryImpl.beforeImport()executesset foreign_key_checks=0andafterImport()executesset foreign_key_checks=1. - Hikari pooling is used, so session variables can persist on a reused connection.
- src/test/java/de/rwth/idsg/steve/repository/impl/DataImportExportRepositoryImplIT.java[55-71]
- Remove the standalone
beforeImport()/afterImport()tests, or combine them into a single test that usestry { beforeImport(); ... } finally { afterImport(); }. - If you want to keep a standalone test for
beforeImport(), add an@AfterEachthat callsrepository.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).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.
onOpen() performs side effects (protocol update + futureResponseContextStore.addSession) before calling sessionContextStore.add(). The store now contains defensive logic that may reject the add.
- 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().
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);
}// 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);
}
}// 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]