diff --git a/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobAcceptedResponse.java b/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobAcceptedResponse.java index 5a2ee88a..5258f6c8 100644 --- a/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobAcceptedResponse.java +++ b/etl-service/src/main/java/com/xtrmetl/etl/job/EtlJobAcceptedResponse.java @@ -8,7 +8,7 @@ * * @param jobRecordId opaque durable job identifier * @param jobStatus current stable job status - * @param statusUrl relative status-monitor resource URL + * @param statusUrl origin-relative status-monitor resource URL beginning with exactly one slash */ public record EtlJobAcceptedResponse( UUID jobRecordId, @@ -21,11 +21,18 @@ public record EtlJobAcceptedResponse( * * @param jobRecordId opaque durable job identifier * @param jobStatus current stable job status - * @param statusUrl relative status-monitor resource URL + * @param statusUrl origin-relative status-monitor resource URL beginning with exactly one slash + * @throws IllegalArgumentException when {@code statusUrl} is blank or is not origin-relative */ public EtlJobAcceptedResponse { Objects.requireNonNull(jobRecordId, "jobRecordId must not be null"); Objects.requireNonNull(jobStatus, "jobStatus must not be null"); Objects.requireNonNull(statusUrl, "statusUrl must not be null"); + if (statusUrl.isBlank()) { + throw new IllegalArgumentException("statusUrl must not be blank"); + } + if (!statusUrl.startsWith("/") || statusUrl.startsWith("//")) { + throw new IllegalArgumentException("statusUrl must be origin-relative"); + } } } diff --git a/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobAcceptedResponseStatusUrlTest.java b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobAcceptedResponseStatusUrlTest.java new file mode 100644 index 00000000..d87e664d --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/job/EtlJobAcceptedResponseStatusUrlTest.java @@ -0,0 +1,64 @@ +package com.xtrmetl.etl.job; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Verifies that accepted durable-job representations expose only valid origin-relative status URLs. + */ +class EtlJobAcceptedResponseStatusUrlTest { + + private static final UUID JOB_RECORD_ID = UUID.fromString( + "cf4f083f-8c90-4f34-a8b6-b53761de44ef" + ); + + @Test + void retainsValidOriginRelativeStatusUrl() { + String statusUrl = "/api/etl/jobs/" + JOB_RECORD_ID; + + EtlJobAcceptedResponse response = new EtlJobAcceptedResponse( + JOB_RECORD_ID, + EtlJobStatus.PENDING, + statusUrl + ); + + assertEquals(statusUrl, response.statusUrl()); + } + + @Test + void rejectsBlankStatusUrls() { + for (String invalidStatusUrl : List.of("", " ", "\t\n")) { + assertThrows( + IllegalArgumentException.class, + () -> new EtlJobAcceptedResponse( + JOB_RECORD_ID, + EtlJobStatus.PENDING, + invalidStatusUrl + ) + ); + } + } + + @Test + void rejectsNonOriginRelativeStatusUrls() { + for (String invalidStatusUrl : List.of( + "https://example.test/api/etl/jobs/" + JOB_RECORD_ID, + "//example.test/api/etl/jobs/" + JOB_RECORD_ID, + "api/etl/jobs/" + JOB_RECORD_ID + )) { + assertThrows( + IllegalArgumentException.class, + () -> new EtlJobAcceptedResponse( + JOB_RECORD_ID, + EtlJobStatus.PENDING, + invalidStatusUrl + ) + ); + } + } +}