diff --git a/Jenkinsfile_CNP b/Jenkinsfile_CNP index b0a91a330..9c2171144 100644 --- a/Jenkinsfile_CNP +++ b/Jenkinsfile_CNP @@ -48,6 +48,8 @@ env.CCD_DEF_API = 'http://ccd-definition-store-api-aat.service.core-compute-aat. env.CCD_DATA_API = 'http://ccd-data-store-api-aat.service.core-compute-aat.internal' env.CDAM_URL = 'http://ccd-case-document-am-api-aat.service.core-compute-aat.internal' env.DOCMOSIS_ENDPOINT = 'https://docmosis.aat.platform.hmcts.net/rs/convert' +env.CALLBACK_DOMAIN= 'em-ccd-orchestrator-aat.service.core-compute-aat.internal' +env.CALLBACK_HTTP_HOST_PORT= 80 def vaultOverrides = [ 'preview' : 'aat', diff --git a/build.gradle b/build.gradle index f4c10b382..2aa73a62f 100644 --- a/build.gradle +++ b/build.gradle @@ -227,6 +227,8 @@ dependencies { testImplementation group: 'com.github.hmcts', name: 'fortify-client', version: '1.4.10', classifier: 'all' + testImplementation group: 'org.wiremock', name: 'wiremock-standalone', version: '3.13.2' + aatRuntimeOnly group: 'jakarta.xml.bind', name: 'jakarta.xml.bind-api', version: '4.0.5' aatRuntimeOnly group: 'org.glassfish.jaxb', name: 'jaxb-runtime', version: '4.0.7' diff --git a/charts/em-stitching/Chart.yaml b/charts/em-stitching/Chart.yaml index 10774658d..de6018ca5 100644 --- a/charts/em-stitching/Chart.yaml +++ b/charts/em-stitching/Chart.yaml @@ -1,6 +1,6 @@ name: em-stitching home: https://github.com/hmcts/rpa-em-stitching-api -version: 1.1.1 +version: 1.1.2 apiVersion: v2 description: Helm chart for the HMCTS EM Stitching API maintainers: diff --git a/charts/em-stitching/values.yaml b/charts/em-stitching/values.yaml index 91e8d5a01..37844df40 100644 --- a/charts/em-stitching/values.yaml +++ b/charts/em-stitching/values.yaml @@ -38,6 +38,7 @@ java: DOCMOSIS_ENDPOINT: https://docmosis.{{ .Values.global.environment }}.platform.hmcts.net/rs/convert DOCMOSIS_RENDER_ENDPOINT: https://docmosis.{{ .Values.global.environment }}.platform.hmcts.net/rs/render CDAM_URL: http://ccd-case-document-am-api-{{ .Values.global.environment }}.service.core-compute-{{ .Values.global.environment }}.internal + CALLBACK_DOMAIN: em-ccd-orchestrator-{{ .Values.global.environment }}.service.core-compute-{{ .Values.global.environment }}.internal ENABLE_DB_MIGRATE: false REFORM_SERVICE_NAME: rpa-em-stitching-api @@ -68,3 +69,5 @@ java: HTTP_CLIENT_CONNECT_TIMEOUT: 30000 HTTP_CLIENT_SOCKET_TIMEOUT: 60000 APPLICATIONINSIGHTS_INSTRUMENTATION_LOGGING_LEVEL: INFO + CALLBACK_HTTP_SCHEME: http + CALLBACK_HTTP_HOST_PORT: 80 diff --git a/src/aat/java/uk/gov/hmcts/reform/em/stitching/functional/BaseTest.java b/src/aat/java/uk/gov/hmcts/reform/em/stitching/functional/BaseTest.java index 2d19e696d..64248e3b5 100644 --- a/src/aat/java/uk/gov/hmcts/reform/em/stitching/functional/BaseTest.java +++ b/src/aat/java/uk/gov/hmcts/reform/em/stitching/functional/BaseTest.java @@ -6,7 +6,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.RegisterExtension; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.test.context.TestPropertySource; +import uk.gov.hmcts.reform.em.stitching.testutil.CallbackMockConfig; import uk.gov.hmcts.reform.em.stitching.testutil.TestUtil; import uk.gov.hmcts.reform.em.test.retry.RetryExtension; @@ -15,6 +17,7 @@ @ExtendWith(SerenityJUnit5Extension.class) @WithTags({@WithTag("testType:Functional")}) @SuppressWarnings("java:S5960") +@Import(CallbackMockConfig.class) public abstract class BaseTest { protected final TestUtil testUtil; diff --git a/src/aat/java/uk/gov/hmcts/reform/em/stitching/functional/DocumentTaskScenarios.java b/src/aat/java/uk/gov/hmcts/reform/em/stitching/functional/DocumentTaskScenarios.java index d548ef1dd..aa846c6dd 100644 --- a/src/aat/java/uk/gov/hmcts/reform/em/stitching/functional/DocumentTaskScenarios.java +++ b/src/aat/java/uk/gov/hmcts/reform/em/stitching/functional/DocumentTaskScenarios.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import uk.gov.hmcts.reform.ccd.client.model.CaseDetails; import uk.gov.hmcts.reform.em.stitching.domain.enumeration.TaskState; import uk.gov.hmcts.reform.em.stitching.service.dto.BundleDTO; import uk.gov.hmcts.reform.em.stitching.service.dto.CallbackDto; @@ -18,6 +19,7 @@ import java.io.File; import java.io.IOException; import java.time.Instant; +import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -25,13 +27,12 @@ import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; import static uk.gov.hmcts.reform.em.stitching.testutil.TestUtil.convertObjectToJsonBytes; - class DocumentTaskScenarios extends BaseTest { private RequestSpecification request; private RequestSpecification unAuthenticatedRequest; + private static final String END_POINT = "/api/document-tasks"; - private static final String CALL_BACK_URL = "https://postman-echo.com/post"; private static final String TASK_STATE = "taskState"; private static final String BUNDLE_S_DOC_URI = "bundle.stitchedDocumentURI"; @@ -43,14 +44,14 @@ protected DocumentTaskScenarios(TestUtil testUtil) { @BeforeEach public void setupRequestSpecification() { request = testUtil - .authRequest() - .baseUri(testUtil.getTestUrl()) - .contentType(APPLICATION_JSON_VALUE); + .authRequest() + .baseUri(testUtil.getTestUrl()) + .contentType(APPLICATION_JSON_VALUE); unAuthenticatedRequest = testUtil - .unauthenticatedRequest() - .baseUri(testUtil.getTestUrl()) - .contentType(APPLICATION_JSON_VALUE); + .unauthenticatedRequest() + .baseUri(testUtil.getTestUrl()) + .contentType(APPLICATION_JSON_VALUE); } @Test @@ -60,9 +61,9 @@ void testPostBundleStitch() throws IOException, InterruptedException { documentTask.setBundle(bundle); Response createTaskResponse = - request - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT); + request + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT); assertEquals(201, createTaskResponse.getStatusCode()); String taskUrl = END_POINT + "/" + createTaskResponse.getBody().jsonPath().getString("id"); @@ -80,11 +81,10 @@ void testPostBundleStitchWithCaseId() throws IOException, InterruptedException { String testCaseId = "TestCaseId967"; documentTask.setCaseId(testCaseId); - Response createTaskResponse = - request - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT); + request + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT); assertEquals(201, createTaskResponse.getStatusCode()); assertEquals(testCaseId, createTaskResponse.getBody().jsonPath().getString("caseId")); @@ -93,7 +93,6 @@ void testPostBundleStitchWithCaseId() throws IOException, InterruptedException { assertEquals(200, getTaskResponse.getStatusCode()); assertNotNull(getTaskResponse.getBody().jsonPath().getString(BUNDLE_S_DOC_URI)); - } @Test @@ -103,9 +102,9 @@ void testPostBundleStitchWithWordDoc() throws IOException, InterruptedException documentTask.setBundle(bundle); Response createTaskResponse = - request - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT); + request + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT); assertEquals(201, createTaskResponse.getStatusCode()); String taskUrl = END_POINT + "/" + createTaskResponse.getBody().jsonPath().getString("id"); @@ -122,9 +121,9 @@ void testPostBundleStitchWithTextFile() throws IOException, InterruptedException documentTask.setBundle(bundle); Response createTaskResponse = - request - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT); + request + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT); assertEquals(201, createTaskResponse.getStatusCode()); String taskUrl = END_POINT + "/" + createTaskResponse.getBody().jsonPath().getString("id"); @@ -141,9 +140,9 @@ void testPostBundleStitchWithRichTextFile() throws IOException, InterruptedExcep documentTask.setBundle(bundle); Response createTaskResponse = - request - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT); + request + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT); assertEquals(201, createTaskResponse.getStatusCode()); String taskUrl = END_POINT + "/" + createTaskResponse.getBody().jsonPath().getString("id"); @@ -161,9 +160,9 @@ void testPostBundleStitchWithExcelAndPpt() throws IOException, InterruptedExcept documentTask.setBundle(bundle); Response createTaskResponse = - request - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT); + request + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT); assertEquals(201, createTaskResponse.getStatusCode()); String taskUrl = END_POINT + "/" + createTaskResponse.getBody().jsonPath().getString("id"); @@ -180,9 +179,9 @@ void testPostBundleStitchWithImage() throws IOException, InterruptedException { documentTask.setBundle(bundle); Response createTaskResponse = - request - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT); + request + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT); assertEquals(201, createTaskResponse.getStatusCode()); String taskUrl = END_POINT + "/" + createTaskResponse.getBody().jsonPath().getString("id"); @@ -199,9 +198,9 @@ void testPostBundleStitchWithDocumentWatermarkImage() throws IOException, Interr documentTask.setBundle(bundle); Response createTaskResponse = - request - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT); + request + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT); assertEquals(201, createTaskResponse.getStatusCode()); String taskUrl = END_POINT + "/" + createTaskResponse.getBody().jsonPath().getString("id"); @@ -230,9 +229,9 @@ void testStitchTwoIdenticalDocuments() throws IOException, InterruptedException documentTask.setBundle(bundle); Response createTaskResponse = - request - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT); + request + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT); assertEquals(201, createTaskResponse.getStatusCode()); String taskUrl = END_POINT + "/" + createTaskResponse.getBody().jsonPath().getString("id"); @@ -249,9 +248,9 @@ void testStitchDocumentsWithSortIndices() throws IOException, InterruptedExcepti documentTask.setBundle(bundle); Response createTaskResponse = - request - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT); + request + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT); String taskUrl = END_POINT + "/" + createTaskResponse.getBody().jsonPath().getString("id"); Response completedResponse = testUtil.pollUntil(taskUrl, body -> body.getString(TASK_STATE).equals("DONE")); @@ -270,37 +269,47 @@ void testStitchDocumentsWithSortIndices() throws IOException, InterruptedExcepti assertTrue(indexOfDocument2 < indexOfDocument1); } - @Test void testPostBundleStitchWithCallback() throws IOException, InterruptedException { + String bundleId = UUID.randomUUID().toString(); + + CaseDetails caseDetails = testUtil.createCaseWithBundle(bundleId); + String realCaseId = String.valueOf(caseDetails.getId()); + + String validCallbackUrl = testUtil.getValidCallbackUrl(realCaseId, bundleId); BundleDTO bundle = testUtil.getTestBundle(); DocumentTaskDTO documentTask = new DocumentTaskDTO(); documentTask.setBundle(bundle); CallbackDto callback = new CallbackDto(); - callback.setCallbackUrl(CALL_BACK_URL); - + callback.setCallbackUrl(validCallbackUrl); documentTask.setCallback(callback); Response createTaskResponse = - request - .log().all() - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT); + request + .log().all() + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT); + assertEquals(201, createTaskResponse.getStatusCode()); - assertEquals(CALL_BACK_URL, - createTaskResponse.getBody().jsonPath().getString("callback.callbackUrl")); String taskUrl = END_POINT + "/" + createTaskResponse.getBody().jsonPath().getString("id"); - testUtil.pollUntil(taskUrl, body -> body.getString("callback.callbackState").equals("SUCCESS")); + testUtil.pollUntil(taskUrl, body -> body.getString("callback.callbackState").equals("SUCCESS")); } @Test void testPostBundleStitchWithCallbackForFailure() throws IOException { + String bundleId = UUID.randomUUID().toString(); + + CaseDetails caseDetails = testUtil.createCaseWithBundle(bundleId); + String realCaseId = String.valueOf(caseDetails.getId()); + + String validCallbackUrl = testUtil.getValidCallbackUrl(realCaseId, bundleId); + CallbackDto callback = new CallbackDto(); - callback.setCallbackUrl(CALL_BACK_URL); + callback.setCallbackUrl(validCallbackUrl); callback.setCreatedBy("callback_dummy1"); callback.setCreatedDate(Instant.now()); callback.setLastModifiedBy("callback_dummmy2"); @@ -317,38 +326,39 @@ void testPostBundleStitchWithCallbackForFailure() throws IOException { documentTask.setLastModifiedDate(Instant.now()); Response createTaskResponse = - request - .log().all() - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT); + request + .log().all() + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT); + assertEquals(400, createTaskResponse.getStatusCode()); assertTrue(createTaskResponse.body().asString().contains("Error saving Document Task")); assertTrue(createTaskResponse.getBody().jsonPath().getString("detail") - .contains("Bundle Title can not be more than 255 Chars")); + .contains("Bundle Title can not be more than 255 Chars")); } @Test - void testPostBundleStitchWithCallbackUrlNotAccessible() throws IOException { + void testPostBundleStitchWithInvalidCallbackUrl() throws IOException { BundleDTO bundle = testUtil.getTestBundle(); DocumentTaskDTO documentTask = new DocumentTaskDTO(); documentTask.setBundle(bundle); CallbackDto callback = new CallbackDto(); - callback.setCallbackUrl("http://localhost:80899/my/callback/resource"); + callback.setCallbackUrl("https://postman-echo.com/post"); documentTask.setCallback(callback); Response createTaskResponse = - request - .log().all() - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT); + request + .log().all() + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT); createTaskResponse.prettyPrint(); assertEquals(400, createTaskResponse.getStatusCode()); assertEquals("callback.callbackUrl", createTaskResponse.getBody().jsonPath().getString("fieldErrors[0].field")); - assertEquals("Connection to the callback URL could not be verified.", + assertEquals("Callback URL must be a valid internal endpoint.", createTaskResponse.getBody().jsonPath().getString("fieldErrors[0].message")); } @@ -360,11 +370,11 @@ void shouldReturn401WhenUnAuthenticatedUserPostBundleStitch() throws IOException documentTask.setBundle(bundle); unAuthenticatedRequest - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT) - .then() - .assertThat() - .statusCode(401); + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT) + .then() + .assertThat() + .statusCode(401); } @Test @@ -373,19 +383,19 @@ void shouldReturn404WhenGetDocumentTaskWithNonExistentId() throws IOException { DocumentTaskDTO documentTask = new DocumentTaskDTO(); documentTask.setBundle(bundle); request - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT) - .then().log().all() - .assertThat() - .statusCode(201); + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT) + .then().log().all() + .assertThat() + .statusCode(201); final long nonExistentId = Long.MAX_VALUE; final String taskUrl = END_POINT + "/" + nonExistentId; request - .get(taskUrl) - .then().log().all() - .assertThat() - .statusCode(404); + .get(taskUrl) + .then().log().all() + .assertThat() + .statusCode(404); } @Test @@ -395,22 +405,22 @@ void shouldReturn401WhenUnAuthenticatedUserGetDocumentTask() throws IOException documentTask.setBundle(bundle); final String documentTaskId = - request - .body(convertObjectToJsonBytes(documentTask)) - .post(END_POINT) - .then() - .assertThat() - .statusCode(201) - .extract() - .jsonPath() - .getString("id"); + request + .body(convertObjectToJsonBytes(documentTask)) + .post(END_POINT) + .then() + .assertThat() + .statusCode(201) + .extract() + .jsonPath() + .getString("id"); final String taskUrl = END_POINT + "/" + documentTaskId; unAuthenticatedRequest - .get(taskUrl) - .then().log().all() - .assertThat() - .statusCode(401); + .get(taskUrl) + .then().log().all() + .assertThat() + .statusCode(401); } @Test @@ -431,7 +441,7 @@ void testPostBundleStitchWithInvalidDocumentUri() throws IOException, Interrupte String taskUrl = END_POINT + "/" + createTaskResponse.getBody().jsonPath().getString("id"); Response failedTaskResponse = testUtil.pollUntil( taskUrl, body -> - body.getString(TASK_STATE).equals("FAILED") + body.getString(TASK_STATE).equals("FAILED") ); assertEquals(200, failedTaskResponse.getStatusCode()); @@ -474,4 +484,4 @@ void testPostBundleStitchWithExternalDomainUrl() throws IOException, Interrupted assertEquals("DONE", getTaskResponse.getBody().jsonPath().getString(TASK_STATE)); assertNotNull(getTaskResponse.getBody().jsonPath().getString(BUNDLE_S_DOC_URI)); } -} +} \ No newline at end of file diff --git a/src/aat/java/uk/gov/hmcts/reform/em/stitching/functional/SecureDocumentTaskScenarios.java b/src/aat/java/uk/gov/hmcts/reform/em/stitching/functional/SecureDocumentTaskScenarios.java index a510c36d4..f0cdaca5c 100644 --- a/src/aat/java/uk/gov/hmcts/reform/em/stitching/functional/SecureDocumentTaskScenarios.java +++ b/src/aat/java/uk/gov/hmcts/reform/em/stitching/functional/SecureDocumentTaskScenarios.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import uk.gov.hmcts.reform.ccd.client.model.CaseDetails; import uk.gov.hmcts.reform.em.stitching.domain.enumeration.TaskState; import uk.gov.hmcts.reform.em.stitching.service.dto.BundleDTO; import uk.gov.hmcts.reform.em.stitching.service.dto.CallbackDto; @@ -18,6 +19,7 @@ import java.io.File; import java.io.IOException; import java.time.Instant; +import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -25,7 +27,6 @@ import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; import static uk.gov.hmcts.reform.em.stitching.testutil.TestUtil.convertObjectToJsonBytes; - public class SecureDocumentTaskScenarios extends BaseTest { private static final String API_DOCUMENT_TASKS = "/api/document-tasks"; @@ -35,8 +36,6 @@ public class SecureDocumentTaskScenarios extends BaseTest { private static final String HASH_TOKEN_PATH = "bundle.hashToken"; private static final String CALLBACK_URL_PATH = "callback.callbackUrl"; private static final String CALLBACK_STATE_PATH = "callback.callbackState"; - private static final String POSTMAN_ECHO_URL = "https://postman-echo.com/post"; - private RequestSpecification request; private RequestSpecification unAuthenticatedRequest; @@ -65,6 +64,7 @@ public void setupRequestSpecification() { documentTask.setServiceAuth(testUtil.getServiceAuth()); } + @Test void testPostBundleStitch() throws IOException, InterruptedException { BundleDTO bundle = testUtil.getCdamTestBundle(); @@ -248,7 +248,7 @@ void testStitchDocumentsWithSortIndices() throws IOException, InterruptedExcepti String stitchedDocumentUri = completedResponse.getBody().jsonPath().getString(STITCHED_DOC_URI_PATH); - //We need to donwload the Stitched Document via Dm-Store and not via CDAM. As at this stage the document is + //We need to download the Stitched Document via Dm-Store and not via CDAM. As at this stage the document is // not yet associated to the case through CCD callBack. File stitchedFile = testUtil.downloadDocument(stitchedDocumentUri); @@ -266,13 +266,18 @@ void testStitchDocumentsWithSortIndices() throws IOException, InterruptedExcepti @Test void testPostBundleStitchWithCallback() throws IOException, InterruptedException { + String bundleId = UUID.randomUUID().toString(); + + CaseDetails caseDetails = testUtil.createCaseWithBundle(bundleId); + String realCaseId = String.valueOf(caseDetails.getId()); + + String validCallbackUrl = testUtil.getValidCallbackUrl(realCaseId, bundleId); BundleDTO bundle = testUtil.getCdamTestBundle(); documentTask.setBundle(bundle); CallbackDto callback = new CallbackDto(); - callback.setCallbackUrl(POSTMAN_ECHO_URL); - + callback.setCallbackUrl(validCallbackUrl); documentTask.setCallback(callback); Response createTaskResponse = @@ -280,19 +285,25 @@ void testPostBundleStitchWithCallback() throws IOException, InterruptedException .log().all() .body(convertObjectToJsonBytes(documentTask)) .post(API_DOCUMENT_TASKS); + assertEquals(201, createTaskResponse.getStatusCode()); - assertEquals(POSTMAN_ECHO_URL, - createTaskResponse.getBody().jsonPath().getString(CALLBACK_URL_PATH)); + assertEquals(validCallbackUrl, createTaskResponse.getBody().jsonPath().getString(CALLBACK_URL_PATH)); String taskUrl = API_DOCUMENT_TASKS + "/" + createTaskResponse.getBody().jsonPath().getString(TASK_ID_PATH); testUtil.pollUntil(taskUrl, body -> body.getString(CALLBACK_STATE_PATH).equals("SUCCESS")); - } @Test void testPostBundleStitchWithCallbackForFailure() throws IOException { + String bundleId = UUID.randomUUID().toString(); + + CaseDetails caseDetails = testUtil.createCaseWithBundle(bundleId); + String realCaseId = String.valueOf(caseDetails.getId()); + + String validCallbackUrl = testUtil.getValidCallbackUrl(realCaseId, bundleId); + CallbackDto callback = new CallbackDto(); - callback.setCallbackUrl(POSTMAN_ECHO_URL); + callback.setCallbackUrl(validCallbackUrl); callback.setCreatedBy("callback_dummy1"); callback.setCreatedDate(Instant.now()); callback.setLastModifiedBy("callback_dummmy2"); @@ -312,20 +323,20 @@ void testPostBundleStitchWithCallbackForFailure() throws IOException { .log().all() .body(convertObjectToJsonBytes(documentTask)) .post(API_DOCUMENT_TASKS); + assertEquals(400, createTaskResponse.getStatusCode()); assertTrue(createTaskResponse.body().asString().contains("Error saving Document Task")); assertTrue(createTaskResponse.getBody().jsonPath().getString("detail") .contains("Bundle Title can not be more than 255 Chars")); - } @Test - void testPostBundleStitchWithCallbackUrlNotAccessible() throws IOException { + void testPostBundleStitchWithInvalidCallbackUrl() throws IOException { BundleDTO bundle = testUtil.getCdamTestBundle(); documentTask.setBundle(bundle); CallbackDto callback = new CallbackDto(); - callback.setCallbackUrl("http://localhost:80899/my/callback/resource"); + callback.setCallbackUrl("https://postman-echo.com/post"); documentTask.setCallback(callback); @@ -339,7 +350,7 @@ void testPostBundleStitchWithCallbackUrlNotAccessible() throws IOException { assertEquals(400, createTaskResponse.getStatusCode()); assertEquals(CALLBACK_URL_PATH, createTaskResponse.getBody().jsonPath().getString("fieldErrors[0].field")); - assertEquals("Connection to the callback URL could not be verified.", + assertEquals("Callback URL must be a valid internal endpoint.", createTaskResponse.getBody().jsonPath().getString("fieldErrors[0].message")); } @@ -400,4 +411,4 @@ void shouldReturn401WhenUnAuthenticatedUserGetDocumentTask() throws IOException .assertThat() .statusCode(401); } -} +} \ No newline at end of file diff --git a/src/aat/java/uk/gov/hmcts/reform/em/stitching/testutil/CallbackMockConfig.java b/src/aat/java/uk/gov/hmcts/reform/em/stitching/testutil/CallbackMockConfig.java new file mode 100644 index 000000000..a986f6f76 --- /dev/null +++ b/src/aat/java/uk/gov/hmcts/reform/em/stitching/testutil/CallbackMockConfig.java @@ -0,0 +1,46 @@ +package uk.gov.hmcts.reform.em.stitching.testutil; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.common.ConsoleNotifier; +import com.github.tomakehurst.wiremock.common.FatalStartupException; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; + +@TestConfiguration +public class CallbackMockConfig { + + private static final Logger log = LoggerFactory.getLogger(CallbackMockConfig.class); + + @Value("${callbackurlvalidator.port:8080}") + private int port; + + @Bean(destroyMethod = "stop") + @ConditionalOnProperty(name = "callbackurlvalidator.host", havingValue = "localhost", matchIfMissing = true) + public WireMockServer callbackWireMockServer() { + WireMockServer wireMockServer = new WireMockServer( + WireMockConfiguration.options() + .port(port) + .notifier(new ConsoleNotifier(true)) + ); + + try { + wireMockServer.start(); + wireMockServer.stubFor(post(urlPathMatching("/api/stitching-complete-callback.*")) + .willReturn(aResponse().withStatus(200))); + log.info("WireMock successfully started on port {} for local testing.", port); + } catch (FatalStartupException e) { + log.warn("WireMock could not bind to port {}. Degrading gracefully. Reason: {}", port, e.getMessage()); + } + + return wireMockServer; + } +} \ No newline at end of file diff --git a/src/aat/java/uk/gov/hmcts/reform/em/stitching/testutil/TestUtil.java b/src/aat/java/uk/gov/hmcts/reform/em/stitching/testutil/TestUtil.java index 1d15eb682..a2850096d 100644 --- a/src/aat/java/uk/gov/hmcts/reform/em/stitching/testutil/TestUtil.java +++ b/src/aat/java/uk/gov/hmcts/reform/em/stitching/testutil/TestUtil.java @@ -76,6 +76,15 @@ public class TestUtil { @Value("${document_management.url}") private String dmApiUrl; + @Value("${callbackurlvalidator.scheme:http}") + private String callbackScheme; + + @Value("${callbackurlvalidator.host:localhost}") + private String callbackHost; + + @Value("${callbackurlvalidator.port:8080}") + private int callbackPort; + private final IdamHelper idamHelper; private final S2sHelper s2sHelper; @@ -882,4 +891,32 @@ public BundleDTO getCdamTestBundleWithSortedDocuments() throws JsonProcessingExc bundle.setDocuments(docs); return bundle; } + + public String getValidCallbackUrl(String caseId, String bundleId) { + String portStr = (callbackPort <= 0) ? "" : ":" + callbackPort; + return String.format("%s://%s%s/api/stitching-complete-callback/%s/asyncStitchingComplete/%s", + callbackScheme, callbackHost, portStr, caseId, bundleId); + } + + public CaseDetails createCaseWithBundle(String bundleId) throws JsonProcessingException { + String payload = String.format(""" + { + "caseTitle": "Callback Test Case", + "caseDocuments": [], + "caseBundles":[ + { + "id": "%s", + "value": { + "id": "%s", + "title": "Callback Bundle", + "description": "Bundle for callback testing" + } + } + ] + }""", bundleId, bundleId); + + return ccdDataHelper.createCase( + STITCHING_TEST_USER_EMAIL, JURISDICTION, getEnvCcdCaseTypeId(), "createCase", + objectMapper.readTree(payload)); + } } \ No newline at end of file diff --git a/src/main/java/uk/gov/hmcts/reform/em/stitching/domain/validation/CallableEndpointValidator.java b/src/main/java/uk/gov/hmcts/reform/em/stitching/domain/validation/CallableEndpointValidator.java index 5f1df2e58..a8aa0863f 100644 --- a/src/main/java/uk/gov/hmcts/reform/em/stitching/domain/validation/CallableEndpointValidator.java +++ b/src/main/java/uk/gov/hmcts/reform/em/stitching/domain/validation/CallableEndpointValidator.java @@ -2,52 +2,58 @@ import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; -import java.net.URI; -import java.net.URL; +import java.util.regex.Pattern; -import static uk.gov.hmcts.reform.em.stitching.service.CloseableCloser.close; +import static java.util.Objects.isNull; +@Component public class CallableEndpointValidator implements ConstraintValidator { - private final Logger log = LoggerFactory.getLogger(CallableEndpointValidator.class); + private static final Logger log = LoggerFactory.getLogger(CallableEndpointValidator.class); - private final OkHttpClient okHttpClient; + private static final String CASE_ID_PATTERN = "\\d{16}"; + private static final String TRIGGER_ID = "asyncStitchingComplete"; + private static final String UUID_PATTERN = + "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"; - public CallableEndpointValidator(OkHttpClient okHttpClient) { - this.okHttpClient = okHttpClient; + private final Pattern compiledPattern; + + public CallableEndpointValidator( + @Value("${callbackurlvalidator.scheme}") String scheme, + @Value("${callbackurlvalidator.host}") String host, + @Value("${callbackurlvalidator.port}") int port) { + + String baseUrl = UriComponentsBuilder.newInstance() + .scheme(scheme) + .host(host) + .port(port) + .build() + .toUriString(); + + String regexPath = String.format("/api/stitching-complete-callback/%s/%s/%s", + CASE_ID_PATTERN, TRIGGER_ID, UUID_PATTERN); + + this.compiledPattern = Pattern.compile("^" + Pattern.quote(baseUrl) + regexPath + "$"); } @Override public boolean isValid(String urlString, ConstraintValidatorContext context) { - boolean valid; - Response response = null; - try { - URL url = new URI(urlString).toURL(); - String urlWithoutPathString = String.format("%s://%s:%d", - url.getProtocol(), - url.getHost(), - url.getPort() < 0 ? url.getDefaultPort() : url.getPort()); - log.debug("Probing callback {}", urlWithoutPathString); - URL urlWithoutPath = new URI(urlWithoutPathString).toURL(); - response = okHttpClient - .newCall(new Request.Builder() - .url(urlWithoutPath) - .build()) - .execute(); - valid = response.code() < 500; - } catch (Exception e) { - log.error(String.format("Callback %s could not be verified", urlString), e); - valid = false; - } finally { - close(response); + if (isNull(urlString) || urlString.isBlank()) { + return false; } + + boolean valid = compiledPattern.matcher(urlString).matches(); + + if (!valid) { + log.warn("Callback URL '{}' is invalid or was not generated by the trusted CallbackUrlCreator", urlString); + } + return valid; } - -} +} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8083ce50c..e501d1511 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -156,6 +156,11 @@ stitching-complete: callback: max-attempts: ${CALLBACK_MAX_ATTEMPTS:3} +callbackurlvalidator: + scheme: ${CALLBACK_HTTP_SCHEME:http} + host: ${CALLBACK_DOMAIN:localhost} + port: ${CALLBACK_HTTP_HOST_PORT:8080} + dbMigration: # When true, the app will run DB migration on startup. # Otherwise, it will just check if all migrations have been applied (and fail to start if not). diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index c8252ec4f..ea633b0ab 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -1,2 +1,2 @@ -CallableEndpoint=Connection to the callback URL could not be verified. +CallableEndpoint=Callback URL must be a valid internal endpoint. NotNull=required diff --git a/src/test/java/uk/gov/hmcts/reform/em/stitching/domain/validation/CallableEndpointValidatorTest.java b/src/test/java/uk/gov/hmcts/reform/em/stitching/domain/validation/CallableEndpointValidatorTest.java index 140f96969..4cb959d82 100644 --- a/src/test/java/uk/gov/hmcts/reform/em/stitching/domain/validation/CallableEndpointValidatorTest.java +++ b/src/test/java/uk/gov/hmcts/reform/em/stitching/domain/validation/CallableEndpointValidatorTest.java @@ -1,98 +1,71 @@ package uk.gov.hmcts.reform.em.stitching.domain.validation; -import okhttp3.Interceptor; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Protocol; -import okhttp3.Response; -import okhttp3.ResponseBody; +import jakarta.validation.ConstraintValidatorContext; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class CallableEndpointValidatorTest { - @Test - void isValidReturn200() { - CallableEndpointValidator callableEndpointValidator = - createValidatorWithMockHttp((Interceptor.Chain chain) -> new Response.Builder() - .body(ResponseBody.create("", MediaType.parse("plain/text"))) - .request(chain.request()) - .message("") - .code(200) - .protocol(Protocol.HTTP_2) - .build()); + private CallableEndpointValidator validator; + private ConstraintValidatorContext mockContext; - assertTrue(callableEndpointValidator.isValid("http://localhost:8089/my/callback/resource", null)); + @BeforeEach + void setUp() { + validator = new CallableEndpointValidator("http", "localhost", 8080); + mockContext = null; } @Test - void isValidReturn400() { - CallableEndpointValidator callableEndpointValidator = - createValidatorWithMockHttp((Interceptor.Chain chain) -> new Response.Builder() - .body(ResponseBody.create("", MediaType.parse("plain/text"))) - .request(chain.request()) - .message("") - .code(400) - .protocol(Protocol.HTTP_2) - .build()); - - assertTrue(callableEndpointValidator.isValid("http://localhost:8089/my/callback/resource", null)); + void isValidReturnsTrueForPerfectMatch() { + String validUrl = "http://localhost:8080/api/stitching-complete-callback/1234567890123456/asyncStitchingComplete/123e4567-e89b-12d3-a456-426614174000"; + assertTrue(validator.isValid(validUrl, mockContext)); } @Test - void isValidReturn500() { - CallableEndpointValidator callableEndpointValidator = - createValidatorWithMockHttp((Interceptor.Chain chain) -> new Response.Builder() - .body(ResponseBody.create("", MediaType.parse("plain/text"))) - .request(chain.request()) - .message("") - .code(500) - .protocol(Protocol.HTTP_2) - .build()); + void isValidReturnsTrueWhenPortIsOmittedByConfig() { + CallableEndpointValidator noPortValidator = new CallableEndpointValidator("https", "my-domain.com", -1); - assertFalse(callableEndpointValidator.isValid("http://localhost:8089/my/callback/resource", null)); + String validUrl = "https://my-domain.com/api/stitching-complete-callback/1234567890123456/asyncStitchingComplete/123e4567-e89b-12d3-a456-426614174000"; + assertTrue(noPortValidator.isValid(validUrl, mockContext)); } @Test - void isValidUnreachable() { - CallableEndpointValidator callableEndpointValidator = createValidatorWithMockHttp((Interceptor.Chain chain) -> { - throw new RuntimeException("x"); - }); - - assertFalse(callableEndpointValidator.isValid("http://localhost:9999/my/callback/resource", null)); + void isValidReturnsFalseForInvalidCaseId() { + String invalidUrl = "http://localhost:8080/api/stitching-complete-callback/123456789012345/asyncStitchingComplete/123e4567-e89b-12d3-a456-426614174000"; + assertFalse(validator.isValid(invalidUrl, mockContext)); } - @Test - void isValidHttpUrlWithoutExplicitPortUsesDefaultPort() { - CallableEndpointValidator callableEndpointValidator = - createValidatorWithMockHttp((Interceptor.Chain chain) -> { - String requestedUrl = chain.request().url().toString(); - assertEquals("http://somehost.com/", requestedUrl); - return new Response.Builder() - .body(ResponseBody.create("", MediaType.parse("plain/text"))) - .request(chain.request()) - .message("") - .code(200) - .protocol(Protocol.HTTP_2) - .build(); - }); - - assertTrue(callableEndpointValidator.isValid("http://somehost.com/some/path", null)); + void isValidReturnsFalseForInvalidTriggerId() { + String invalidUrl = "http://localhost:8080/api/stitching-complete-callback/1234567890123456/wrongTriggerId/123e4567-e89b-12d3-a456-426614174000"; + assertFalse(validator.isValid(invalidUrl, mockContext)); } - private CallableEndpointValidator createValidatorWithMockHttp(Interceptor interceptor) { - OkHttpClient http = new OkHttpClient - .Builder() - .addInterceptor(interceptor) - .build(); - - return new CallableEndpointValidator(http); + @Test + void isValidReturnsFalseForInvalidBundleId() { + String invalidUrl = "http://localhost:8080/api/stitching-complete-callback/1234567890123456/asyncStitchingComplete/not-a-uuid"; + assertFalse(validator.isValid(invalidUrl, mockContext)); } -} + @Test + void isValidReturnsFalseForWrongHostOrScheme() { + String invalidUrl = "https://wrong-host:8080/api/stitching-complete-callback/1234567890123456/asyncStitchingComplete/123e4567-e89b-12d3-a456-426614174000"; + assertFalse(validator.isValid(invalidUrl, mockContext)); + } + @Test + void isValidReturnsFalseForAppendedPaths() { + String invalidUrl = "http://localhost:8080/api/stitching-complete-callback/1234567890123456/asyncStitchingComplete/123e4567-e89b-12d3-a456-426614174000/extra"; + assertFalse(validator.isValid(invalidUrl, mockContext)); + } + @Test + void isValidReturnsFalseForNullOrBlank() { + assertFalse(validator.isValid(null, mockContext)); + assertFalse(validator.isValid("", mockContext)); + assertFalse(validator.isValid(" ", mockContext)); + } +} \ No newline at end of file