diff --git a/Dockerfile b/Dockerfile index 64234c3..2383512 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index b83f65c..6ae78ad 100644 --- a/README.md +++ b/README.md @@ -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 ...` > @@ -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** diff --git a/src/main/java/co/nxtgrid/api/IssueDateDeserializer.java b/src/main/java/co/nxtgrid/api/IssueDateDeserializer.java index 5871c9b..697077e 100644 --- a/src/main/java/co/nxtgrid/api/IssueDateDeserializer.java +++ b/src/main/java/co/nxtgrid/api/IssueDateDeserializer.java @@ -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 { diff --git a/src/main/java/co/nxtgrid/api/TokenRequest.java b/src/main/java/co/nxtgrid/api/TokenRequest.java index cd78b69..7a9d73d 100644 --- a/src/main/java/co/nxtgrid/api/TokenRequest.java +++ b/src/main/java/co/nxtgrid/api/TokenRequest.java @@ -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 { @@ -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" @@ -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") diff --git a/src/main/java/co/nxtgrid/strategy/StrategySupport.java b/src/main/java/co/nxtgrid/strategy/StrategySupport.java index a4c6132..3b9678b 100644 --- a/src/main/java/co/nxtgrid/strategy/StrategySupport.java +++ b/src/main/java/co/nxtgrid/strategy/StrategySupport.java @@ -1,9 +1,10 @@ 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 { @@ -11,6 +12,15 @@ 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 + ); } } diff --git a/src/main/java/co/nxtgrid/token/domain/BaseDate.java b/src/main/java/co/nxtgrid/token/domain/BaseDate.java index b47a373..58012aa 100755 --- a/src/main/java/co/nxtgrid/token/domain/BaseDate.java +++ b/src/main/java/co/nxtgrid/token/domain/BaseDate.java @@ -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; diff --git a/src/test/java/co/nxtgrid/TokenControllerValidationTest.java b/src/test/java/co/nxtgrid/TokenControllerValidationTest.java index 2998d18..f0309c3 100644 --- a/src/test/java/co/nxtgrid/TokenControllerValidationTest.java +++ b/src/test/java/co/nxtgrid/TokenControllerValidationTest.java @@ -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(