Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ RUN addgroup -S sts && adduser -S sts -G sts
WORKDIR /app
COPY --from=build /app/target/nxt-sts-*.jar app.jar
USER sts
ENV SERVER_PORT=8080
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
CMD wget -qO- "http://127.0.0.1:${SERVER_PORT}/actuator/health" || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,18 @@ docker run -p 8080:8080 \
nxt-sts
```

When using a custom port, set `SERVER_PORT` and map the same host port:

```bash
docker run -p 9090:9090 \
-e SERVER_PORT=9090 \
nxt-sts
```

> **Health checks**
>
> The image includes a Docker `HEALTHCHECK` that probes `/actuator/health` every 30 seconds.
> The image includes a Docker `HEALTHCHECK` that probes `/actuator/health` every 30 seconds
> on the port given by `SERVER_PORT` (default 8080).
> When running with plain Docker, you can disable it if your environment performs its own
> checks: `docker run --no-healthcheck ...`
>
Expand Down Expand Up @@ -230,12 +239,12 @@ Generates a prepayment token.
>
> See the full schema in the [Swagger UI](http://localhost:8080/swagger).

> **`issueDate` — wall-clock semantics**
> **`issueDate` — wall-clock semantics (UTC)**
>
> Accepts ISO 8601 forms such as `"2024-03-15T10:30:00"`, `"2026-07-07T10:12:54.289"`,
> or `"2026-07-07T10:12:54.289Z"`. Optional fractional seconds and UTC/offset suffixes
> are allowed. Any time-zone offset is **ignored**; the date and time fields are passed
> to token generation unchanged.
> are allowed. Any time-zone offset is **ignored**; the date and time fields are
> interpreted as **UTC** for TID calculation, independent of the server's timezone.

**Example — TOP_UP**

Expand Down
2 changes: 1 addition & 1 deletion src/main/java/co/nxtgrid/api/IssueDateDeserializer.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

/**
* Accepts ISO 8601 local datetimes and offset/UTC forms. Any time-zone offset is ignored;
* the wall-clock date and time fields are what token generation uses.
* the wall-clock date and time fields are interpreted as UTC for token generation.
*/
public class IssueDateDeserializer extends JsonDeserializer<LocalDateTime> {

Expand Down
15 changes: 10 additions & 5 deletions src/main/java/co/nxtgrid/api/TokenRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;

@Schema(description = "Request to generate a 20-digit IEC 62055-41 STS prepayment token")
public class TokenRequest {
Expand All @@ -31,7 +32,7 @@ public class TokenRequest {
@Schema(
description = "Token issue date/time in ISO 8601 format. Optional fractional seconds and "
+ "UTC/offset suffixes are accepted; any offset is ignored and the wall-clock date "
+ "and time fields are passed to token generation unchanged.",
+ "and time fields are interpreted as UTC for token generation.",
example = "2024-03-15T10:30:00",
type = "string",
format = "date-time"
Expand All @@ -54,15 +55,19 @@ public class TokenRequest {
private Integer randomNumber;

@Schema(
description = "Amount of electricity credit in kWh. Required when type is TOP_UP.",
example = "0.5"
description = "Amount of electricity credit in kWh. Required when type is TOP_UP. Must be zero or greater.",
example = "0.5",
minimum = "0"
)
@PositiveOrZero(message = "kwh must be zero or greater")
private Double kwh;

@Schema(
description = "Maximum power limit value. Required when type is SET_POWER_LIMIT.",
example = "5000"
description = "Maximum power limit value. Required when type is SET_POWER_LIMIT. Must be zero or greater.",
example = "5000",
minimum = "0"
)
@PositiveOrZero(message = "powerLimit must be zero or greater")
private Long powerLimit;

@AssertTrue(message = "kwh is required for TOP_UP")
Expand Down
14 changes: 12 additions & 2 deletions src/main/java/co/nxtgrid/strategy/StrategySupport.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
package co.nxtgrid.strategy;

import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoField;

import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;

final class StrategySupport {

private StrategySupport() {
}

static DateTime toJodaDateTime(LocalDateTime issueDate) {
return new DateTime(issueDate.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
return new DateTime(
issueDate.getYear(),
issueDate.getMonthValue(),
issueDate.getDayOfMonth(),
issueDate.getHour(),
issueDate.getMinute(),
issueDate.getSecond(),
issueDate.get(ChronoField.MILLI_OF_SECOND),
DateTimeZone.UTC
);
Comment on lines +15 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using the Joda-Time DateTime constructor without specifying a timezone implicitly uses the system's default timezone (DateTimeZone.getDefault()). If the server is running in a timezone that observes Daylight Saving Time (DST), this constructor will throw an org.joda.time.IllegalInstantException if the requested issueDate falls within a DST gap (when clocks jump forward and that local time does not exist).

This is a regression compared to the original code, which used LocalDateTime.atZone(ZoneId.systemDefault()) and automatically adjusted the local time forward to a valid instant instead of throwing an exception.

To prevent runtime crashes during DST transitions and to ensure consistent, timezone-independent token generation (since STS standard TID calculations rely on exact wall-clock minutes since a base date), it is highly recommended to construct the Joda DateTime in UTC (DateTimeZone.UTC).

        return new DateTime(
            issueDate.getYear(),
            issueDate.getMonthValue(),
            issueDate.getDayOfMonth(),
            issueDate.getHour(),
            issueDate.getMinute(),
            issueDate.getSecond(),
            issueDate.get(ChronoField.MILLI_OF_SECOND),
            org.joda.time.DateTimeZone.UTC
        );

}
Comment thread
bobbybol marked this conversation as resolved.
}
7 changes: 4 additions & 3 deletions src/main/java/co/nxtgrid/token/domain/BaseDate.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package co.nxtgrid.token.domain;

import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;

public enum BaseDate implements Entity {
_1993("93", new DateTime(1993, 1, 1, 0, 0, 0)),
_2014("14", new DateTime(2014, 1, 1, 0, 0, 0)),
_2035("35", new DateTime(2035, 1, 1, 0, 0, 0)); // '+1' added for JodaTime
_1993("93", new DateTime(1993, 1, 1, 0, 0, 0, DateTimeZone.UTC)),
_2014("14", new DateTime(2014, 1, 1, 0, 0, 0, DateTimeZone.UTC)),
_2035("35", new DateTime(2035, 1, 1, 0, 0, 0, DateTimeZone.UTC)); // '+1' added for JodaTime

public DateTime dateTime;
public String shortCode;
Expand Down
88 changes: 88 additions & 0 deletions src/test/java/co/nxtgrid/TokenControllerValidationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,94 @@ void rejectsMissingKwhForTopUp() throws Exception {
.andExpect(jsonPath("$.error").value("kwh is required for TOP_UP"));
}

@Test
void acceptsZeroKwhForTopUp() throws Exception {
mockMvc.perform(
post("/token")
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{
"type": "TOP_UP",
"issueDate": "2024-03-15T10:30:00",
"randomNumber": 3,
"decoderKey": "0123456789ABCDEF",
"kwh": 0
}
"""
)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").isString())
.andExpect(jsonPath("$.token").isNotEmpty());
}

@Test
void rejectsNegativeKwhForTopUp() throws Exception {
mockMvc.perform(
post("/token")
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{
"type": "TOP_UP",
"issueDate": "2024-03-15T10:30:00",
"randomNumber": 3,
"decoderKey": "0123456789ABCDEF",
"kwh": -0.5
}
"""
)
)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("kwh must be zero or greater"))
.andExpect(jsonPath("$.field").value("kwh"));
}

@Test
void acceptsZeroPowerLimitForSetPowerLimit() throws Exception {
mockMvc.perform(
post("/token")
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{
"type": "SET_POWER_LIMIT",
"issueDate": "2024-03-15T10:30:00",
"randomNumber": 3,
"decoderKey": "0123456789ABCDEF",
"powerLimit": 0
}
"""
)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").isString())
.andExpect(jsonPath("$.token").isNotEmpty());
}

@Test
void rejectsNegativePowerLimitForSetPowerLimit() throws Exception {
mockMvc.perform(
post("/token")
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{
"type": "SET_POWER_LIMIT",
"issueDate": "2024-03-15T10:30:00",
"randomNumber": 3,
"decoderKey": "0123456789ABCDEF",
"powerLimit": -100
}
"""
)
)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("powerLimit must be zero or greater"))
.andExpect(jsonPath("$.field").value("powerLimit"));
}

@Test
void rejectsMalformedIssueDate() throws Exception {
mockMvc.perform(
Expand Down
Loading