From 5f92b634919fd375028547ea4b3c5e407943dce5 Mon Sep 17 00:00:00 2001 From: Chetan Munegowda Date: Fri, 6 Feb 2026 10:25:30 -0500 Subject: [PATCH 01/19] QPPA-10786: increase test coverage --- .../correlation/model/CorrelationTest.java | 54 +++ .../correlation/model/GoodsTest.java | 30 ++ .../correlation/model/TemplateTest.java | 30 ++ .../api/RestApiApplicationTest.java | 20 +- .../api/controllers/v2/ZipControllerTest.java | 197 +++++---- .../api/helper/AdvancedApmHelperTest.java | 99 +++++ .../qpp/conversion/api/model/ReportTest.java | 75 ++++ .../security/JwtAuthorizationFilterTest.java | 274 ++++++------- .../AdvancedApmFileServiceImplTest.java | 118 ++++++ .../internal/AuditServiceImplTest.java | 382 ++++++++---------- .../internal/QrdaServiceImplTest.java | 179 ++++---- .../internal/ValidationServiceImplTest.java | 27 +- test-coverage/pom.xml | 6 + 13 files changed, 924 insertions(+), 567 deletions(-) create mode 100644 converter/src/test/java/gov/cms/qpp/conversion/correlation/model/CorrelationTest.java create mode 100644 converter/src/test/java/gov/cms/qpp/conversion/correlation/model/GoodsTest.java create mode 100644 converter/src/test/java/gov/cms/qpp/conversion/correlation/model/TemplateTest.java create mode 100644 rest-api/src/test/java/gov/cms/qpp/conversion/api/helper/AdvancedApmHelperTest.java create mode 100644 rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/AdvancedApmFileServiceImplTest.java diff --git a/converter/src/test/java/gov/cms/qpp/conversion/correlation/model/CorrelationTest.java b/converter/src/test/java/gov/cms/qpp/conversion/correlation/model/CorrelationTest.java new file mode 100644 index 0000000000..166403cea6 --- /dev/null +++ b/converter/src/test/java/gov/cms/qpp/conversion/correlation/model/CorrelationTest.java @@ -0,0 +1,54 @@ +package gov.cms.qpp.conversion.correlation.model; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +class CorrelationTest { + + @Test + void gettersAndSetters_work() { + Correlation correlation = new Correlation(); + + correlation.setCorrelationId("corr-1"); + assertThat(correlation.getCorrelationId()).isEqualTo("corr-1"); + } + + @Test + void getConfig_returnsDefensiveCopy_changesToReturnedListDontAffectInternalState() { + Correlation correlation = new Correlation(); + + List first = correlation.getConfig(); + assertThat(first).isEmpty(); + + first.add(new CorrelationConfig()); + + assertThat(correlation.getConfig()).isEmpty(); + } + + @Test + void setConfig_copiesInputList_laterMutationsToInputDontAffectInternalState() { + Correlation correlation = new Correlation(); + + List input = new ArrayList<>(); + input.add(new CorrelationConfig()); + + correlation.setConfig(input); + assertThat(correlation.getConfig()).hasSize(1); + + input.add(new CorrelationConfig()); + + assertThat(correlation.getConfig()).hasSize(1); + } + + @Test + void setConfig_null_throwsNpe() { + Correlation correlation = new Correlation(); + + assertThrows(NullPointerException.class, () -> correlation.setConfig(null)); + } +} diff --git a/converter/src/test/java/gov/cms/qpp/conversion/correlation/model/GoodsTest.java b/converter/src/test/java/gov/cms/qpp/conversion/correlation/model/GoodsTest.java new file mode 100644 index 0000000000..faa31cee34 --- /dev/null +++ b/converter/src/test/java/gov/cms/qpp/conversion/correlation/model/GoodsTest.java @@ -0,0 +1,30 @@ +package gov.cms.qpp.conversion.correlation.model; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +class GoodsTest { + + @Test + void gettersAndSetters_work() { + Goods goods = new Goods(); + + goods.setRelativeXPath("/ClinicalDocument/id"); + goods.setXmltype("QRDA-III"); + + assertThat(goods.getRelativeXPath()).isEqualTo("/ClinicalDocument/id"); + assertThat(goods.getXmltype()).isEqualTo("QRDA-III"); + } + + @Test + void allowsNulls() { + Goods goods = new Goods(); + + goods.setRelativeXPath(null); + goods.setXmltype(null); + + assertThat(goods.getRelativeXPath()).isNull(); + assertThat(goods.getXmltype()).isNull(); + } +} diff --git a/converter/src/test/java/gov/cms/qpp/conversion/correlation/model/TemplateTest.java b/converter/src/test/java/gov/cms/qpp/conversion/correlation/model/TemplateTest.java new file mode 100644 index 0000000000..b5e342d350 --- /dev/null +++ b/converter/src/test/java/gov/cms/qpp/conversion/correlation/model/TemplateTest.java @@ -0,0 +1,30 @@ +package gov.cms.qpp.conversion.correlation.model; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +class TemplateTest { + + @Test + void gettersAndSetters_work() { + Template template = new Template(); + + template.setTemplateId("2.16.840.1.113883.10.20.27.3.1"); + template.setCorrelationId("corr-123"); + + assertThat(template.getTemplateId()).isEqualTo("2.16.840.1.113883.10.20.27.3.1"); + assertThat(template.getCorrelationId()).isEqualTo("corr-123"); + } + + @Test + void allowsNulls() { + Template template = new Template(); + + template.setTemplateId(null); + template.setCorrelationId(null); + + assertThat(template.getTemplateId()).isNull(); + assertThat(template.getCorrelationId()).isNull(); + } +} diff --git a/rest-api/src/test/java/gov/cms/qpp/conversion/api/RestApiApplicationTest.java b/rest-api/src/test/java/gov/cms/qpp/conversion/api/RestApiApplicationTest.java index c9743ae26e..a16c57eb33 100644 --- a/rest-api/src/test/java/gov/cms/qpp/conversion/api/RestApiApplicationTest.java +++ b/rest-api/src/test/java/gov/cms/qpp/conversion/api/RestApiApplicationTest.java @@ -1,19 +1,13 @@ package gov.cms.qpp.conversion.api; -import org.junit.jupiter.api.Test; +import static org.junit.Assert.assertNotNull; -@SpringTest -class RestApiApplicationTest { +import org.junit.Test; -// @Test -// void contextLoads() { -// } -// -// @Test -// void testMain() { -// RestApiApplication.main(); -// -// -// } +public class RestApiApplicationTest { + @Test + public void mainMethodExists() throws Exception { + assertNotNull(RestApiApplication.class.getMethod("main", String[].class)); + } } diff --git a/rest-api/src/test/java/gov/cms/qpp/conversion/api/controllers/v2/ZipControllerTest.java b/rest-api/src/test/java/gov/cms/qpp/conversion/api/controllers/v2/ZipControllerTest.java index be8e49eaca..7be5c52bc0 100644 --- a/rest-api/src/test/java/gov/cms/qpp/conversion/api/controllers/v2/ZipControllerTest.java +++ b/rest-api/src/test/java/gov/cms/qpp/conversion/api/controllers/v2/ZipControllerTest.java @@ -3,34 +3,29 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; +import java.io.UncheckedIOException; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; import gov.cms.qpp.conversion.ConversionReport; import gov.cms.qpp.conversion.Source; -import gov.cms.qpp.conversion.api.exceptions.AuditException; import gov.cms.qpp.conversion.api.exceptions.BadZipException; import gov.cms.qpp.conversion.api.model.ConvertResponse; import gov.cms.qpp.conversion.api.model.Metadata; @@ -38,21 +33,11 @@ import gov.cms.qpp.conversion.api.services.QrdaService; import gov.cms.qpp.conversion.api.services.ValidationService; import gov.cms.qpp.conversion.encode.JsonWrapper; -import gov.cms.qpp.conversion.model.error.TransformException; import gov.cms.qpp.test.MockitoExtension; @ExtendWith(MockitoExtension.class) class ZipControllerTest { - private static final String GOOD_FILE_CONTENT = "good-file"; - - static final Path validationJsonFilePath = Path.of("src/test/resources/testCpcPlusValidationFile.json"); - static final Path goodZipFilePath = Path.of("src/test/resources/good-file.zip"); - - private MultipartFile multipartFile; - - private InputStream validationInputStream; - @InjectMocks private ZipController objectUnderTest; @@ -68,125 +53,127 @@ class ZipControllerTest { @Mock private ConversionReport report; - @Mock - private CompletableFuture mockMetadata;// = Mockito.mock(CompletableFuture.class); - - @BeforeEach - void initialization() throws IOException { + void setup() { JsonWrapper wrapper = new JsonWrapper(); wrapper.put("key", "Good Qpp"); - validationInputStream = Files.newInputStream(validationJsonFilePath); - when(report.getEncodedWithMetadata()).thenReturn(wrapper); - - multipartFile = new MockMultipartFile(GOOD_FILE_CONTENT, Files.newInputStream(goodZipFilePath)); + when(qrdaService.retrieveCpcPlusValidationFile()).thenReturn(null); } @Test - void uploadQrdaFile() { - Metadata metadata = Metadata.create(); + void uploadQrdaFile_happyPath_returnsResponsesForFiles() throws Exception { + byte[] zipBytes = zipOf( + entry("a.xml", "".getBytes()), + entry("b.xml", "".getBytes()) + ); + + MultipartFile multipartFile = + new MockMultipartFile("good.zip", "good.zip", "application/zip", zipBytes); + when(qrdaService.convertQrda3ToQpp(any(Source.class))).thenReturn(report); - when(qrdaService.retrieveCpcPlusValidationFile()).thenReturn(validationInputStream); - when(auditService.success(any(ConversionReport.class))) - .then(invocation -> CompletableFuture.completedFuture(metadata)); + when(auditService.success(any(ConversionReport.class))).thenReturn(null); - ResponseEntity> qppResponse = objectUnderTest.uploadQrdaFile(multipartFile, null); + ResponseEntity> response = + objectUnderTest.uploadQrdaFile(multipartFile, null); + assertThat(response.getBody()).hasSize(2); + assertThat(response.getBody().get(0).getQpp()).isNotNull(); verify(qrdaService, atLeastOnce()).convertQrda3ToQpp(any(Source.class)); - - assertThat(qppResponse.getBody().get(0).getQpp().toString()) - .isEqualTo("{\n" - + " \"key\" : \"Good Qpp\"\n" - + "}"); } @Test - void uploadTestQrdaFile() { - ArgumentCaptor peopleCaptor = ArgumentCaptor.forClass(Source.class); + void uploadQrdaFile_skipsDirectoryEntries() throws Exception { + byte[] zipBytes = zipOf( + dir("folder/"), + entry("folder/a.xml", "".getBytes()) + ); - when(qrdaService.convertQrda3ToQpp(peopleCaptor.capture())).thenReturn(report); - when(qrdaService.retrieveCpcPlusValidationFile()).thenReturn(validationInputStream); - when(auditService.success(any(ConversionReport.class))) - .then(invocation -> null); + MultipartFile multipartFile = new MockMultipartFile("good.zip", "good.zip", "application/zip", zipBytes); - when(report.getPurpose()).thenReturn("Test"); - ResponseEntity> qppResponse = objectUnderTest.uploadQrdaFile(multipartFile, "Test"); + when(qrdaService.convertQrda3ToQpp(any(Source.class))).thenReturn(report); + when(auditService.success(any(ConversionReport.class))).thenReturn(null); - assertThat(qppResponse).isNotNull(); - assertThat(peopleCaptor.getValue().getPurpose()).isEqualTo("Test"); - } + ResponseEntity> response = objectUnderTest.uploadQrdaFile(multipartFile, null); - @Test - void uploadNullQrdaFile() { - Assertions.assertThrows(BadZipException.class, () -> { - objectUnderTest.uploadQrdaFile(new MockMultipartFile("null.zip", new byte[0]), "Test"); - }); + assertThat(response.getBody()).hasSize(2); + + verify(qrdaService, atLeastOnce()).convertQrda3ToQpp(any(Source.class)); } @Test - void uploadQrdaFile_auditInterruptionException() throws Exception { - ArgumentCaptor peopleCaptor = ArgumentCaptor.forClass(Source.class); - - when(qrdaService.convertQrda3ToQpp(peopleCaptor.capture())).thenReturn(report); - when(qrdaService.retrieveCpcPlusValidationFile()).thenReturn(validationInputStream); - when(auditService.success(any(ConversionReport.class))).thenReturn(mockMetadata); - when(mockMetadata.get()).thenThrow(new InterruptedException("Testing Audit Exception Handling")); - - String purpose = "Test"; - when(report.getPurpose()).thenReturn(purpose); - assertThrows(AuditException.class, () -> objectUnderTest.uploadQrdaFile(multipartFile, purpose)); + void uploadQrdaFile_invalidZipBytes_throwsBadZipException() { + MultipartFile multipartFile = new MockMultipartFile( + "bad.zip", "bad.zip", "application/zip", "not-a-zip".getBytes()); + + assertThrows(BadZipException.class, () -> objectUnderTest.uploadQrdaFile(multipartFile, null)); } @Test - void uploadQrdaFile_auditExecutionException() throws Exception { - ArgumentCaptor peopleCaptor = ArgumentCaptor.forClass(Source.class); - - when(qrdaService.convertQrda3ToQpp(peopleCaptor.capture())).thenReturn(report); - when(qrdaService.retrieveCpcPlusValidationFile()).thenReturn(validationInputStream); - when(auditService.success(any(ConversionReport.class))).thenReturn(mockMetadata); - when(mockMetadata.get()).thenThrow(new ExecutionException(new RuntimeException("Testing Audit Exception Handling"))); - - String purpose = "Test"; - when(report.getPurpose()).thenReturn(purpose); - assertThrows(AuditException.class, () -> objectUnderTest.uploadQrdaFile(multipartFile, purpose)); + void uploadQrdaFile_whenTransferToThrowsIOException_throwsUncheckedIOException() throws Exception { + MultipartFile brokenFile = org.mockito.Mockito.mock(MultipartFile.class); + + org.mockito.Mockito.doThrow(new IOException("boom")) + .when(brokenFile) + .transferTo(any(java.io.File.class)); + + assertThrows(UncheckedIOException.class, () -> objectUnderTest.uploadQrdaFile(brokenFile, null)); } @Test - void uploadQrdaFile_nullCpcValidationMap() { - ArgumentCaptor peopleCaptor = ArgumentCaptor.forClass(Source.class); + void uploadQrdaFile_whenAuditReturnsMetadata_setsLocation() throws Exception { + byte[] zipBytes = zipOf(entry("a.xml", "".getBytes())); + MultipartFile multipartFile = + new MockMultipartFile("good.zip", "good.zip", "application/zip", zipBytes); - when(qrdaService.convertQrda3ToQpp(peopleCaptor.capture())).thenReturn(report); - when(qrdaService.retrieveCpcPlusValidationFile()).thenReturn(null); - when(auditService.success(any(ConversionReport.class))).then(invocation -> null); + when(qrdaService.convertQrda3ToQpp(any(Source.class))).thenReturn(report); + + Metadata metadata = Metadata.create(); + metadata.setUuid("uuid-123"); + + when(auditService.success(any(ConversionReport.class))) + .thenReturn(CompletableFuture.completedFuture(metadata)); - String purpose = "Test"; - when(report.getPurpose()).thenReturn(purpose); - ResponseEntity> qppResponse = objectUnderTest.uploadQrdaFile(multipartFile, purpose); - - assertThat(qppResponse).isNotNull(); + ResponseEntity> response = + objectUnderTest.uploadQrdaFile(multipartFile, null); + + assertThat(response.getBody()).hasSize(1); + assertThat(response.getBody().get(0).getLocation()).isEqualTo("uuid-123"); } - @Test - void testFailedQppValidation() { - String transformationErrorMessage = "Test failed QPP validation"; - - when(qrdaService.convertQrda3ToQpp(any(Source.class))) - .thenReturn(null); - when(qrdaService.retrieveCpcPlusValidationFile()).thenReturn(validationInputStream); - Mockito.doThrow(new TransformException(transformationErrorMessage, null, null)) - .when(validationService).validateQpp(isNull()); - - try { - ResponseEntity> qppResponse = objectUnderTest.uploadQrdaFile(multipartFile, null); - Assertions.fail("An exception should have occurred. Instead was " + qppResponse); - } catch(TransformException exception) { - assertThat(exception.getMessage()) - .isEqualTo(transformationErrorMessage); - } catch (Exception exception) { - Assertions.fail("The wrong exception occurred."); + private static ZipSpec entry(String name, byte[] bytes) { + return new ZipSpec(name, bytes, false); + } + + private static ZipSpec dir(String name) { + return new ZipSpec(name, new byte[0], true); + } + + private static byte[] zipOf(ZipSpec... specs) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (ZipSpec spec : specs) { + ZipEntry ze = new ZipEntry(spec.name); + zos.putNextEntry(ze); + if (!spec.isDir && spec.bytes.length > 0) { + zos.write(spec.bytes); + } + zos.closeEntry(); + } } + return baos.toByteArray(); } + private static final class ZipSpec { + private final String name; + private final byte[] bytes; + private final boolean isDir; + + private ZipSpec(String name, byte[] bytes, boolean isDir) { + this.name = name; + this.bytes = bytes; + this.isDir = isDir; + } + } } diff --git a/rest-api/src/test/java/gov/cms/qpp/conversion/api/helper/AdvancedApmHelperTest.java b/rest-api/src/test/java/gov/cms/qpp/conversion/api/helper/AdvancedApmHelperTest.java new file mode 100644 index 0000000000..a0f0d55a19 --- /dev/null +++ b/rest-api/src/test/java/gov/cms/qpp/conversion/api/helper/AdvancedApmHelperTest.java @@ -0,0 +1,99 @@ +package gov.cms.qpp.conversion.api.helper; + +import static com.google.common.truth.Truth.assertThat; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import gov.cms.qpp.conversion.api.model.Constants; +import gov.cms.qpp.conversion.api.model.Metadata; +import gov.cms.qpp.conversion.api.model.UnprocessedFileData; + +class AdvancedApmHelperTest { + + @AfterEach + void tearDown() { + System.clearProperty(Constants.NO_CPC_PLUS_API_ENV_VARIABLE); + } + + @Test + void isPcfFile_nullMetadata_false() { + assertThat(AdvancedApmHelper.isPcfFile(null)).isFalse(); + } + + @Test + void isPcfFile_pcfNull_false() { + Metadata m = new Metadata(); + m.setPcf(null); + + assertThat(AdvancedApmHelper.isPcfFile(m)).isFalse(); + } + + @Test + void isPcfFile_pcfSet_true() { + Metadata m = new Metadata(); + m.setPcf("PCF"); + + assertThat(AdvancedApmHelper.isPcfFile(m)).isTrue(); + } + + @Test + void isAValidUnprocessedFile_notPcf_false() { + Metadata m = new Metadata(); + m.setPcf(null); + m.setCpcProcessed(false); + m.setRtiProcessed(false); + + assertThat(AdvancedApmHelper.isAValidUnprocessedFile(m)).isFalse(); + } + + @Test + void isAValidUnprocessedFile_pcf_andEitherFlagFalse_true() { + Metadata m = new Metadata(); + m.setPcf("PCF"); + m.setCpcProcessed(true); + m.setRtiProcessed(false); + + assertThat(AdvancedApmHelper.isAValidUnprocessedFile(m)).isTrue(); + } + + @Test + void isAValidUnprocessedFile_pcf_andBothTrue_false() { + Metadata m = new Metadata(); + m.setPcf("PCF"); + m.setCpcProcessed(true); + m.setRtiProcessed(true); + + assertThat(AdvancedApmHelper.isAValidUnprocessedFile(m)).isFalse(); + } + + @Test + void transformMetaDataToUnprocessedFileData_mapsAllItems() { + Metadata m1 = new Metadata(); + Metadata m2 = new Metadata(); + + List result = + AdvancedApmHelper.transformMetaDataToUnprocessedFileData(Arrays.asList(m1, m2)); + + assertThat(result).hasSize(2); + assertThat(result.get(0)).isInstanceOf(UnprocessedFileData.class); + assertThat(result.get(1)).isInstanceOf(UnprocessedFileData.class); + } + + @Test + void blockAdvancedApmApis_whenEnvVarNotPresent_false() { + System.clearProperty(Constants.NO_CPC_PLUS_API_ENV_VARIABLE); + + assertThat(AdvancedApmHelper.blockAdvancedApmApis()).isFalse(); + } + + @Test + void blockAdvancedApmApis_whenEnvVarPresent_true() { + System.setProperty(Constants.NO_CPC_PLUS_API_ENV_VARIABLE, "true"); + + assertThat(AdvancedApmHelper.blockAdvancedApmApis()).isTrue(); + } +} diff --git a/rest-api/src/test/java/gov/cms/qpp/conversion/api/model/ReportTest.java b/rest-api/src/test/java/gov/cms/qpp/conversion/api/model/ReportTest.java index d69f05b390..0f1f4fb968 100644 --- a/rest-api/src/test/java/gov/cms/qpp/conversion/api/model/ReportTest.java +++ b/rest-api/src/test/java/gov/cms/qpp/conversion/api/model/ReportTest.java @@ -1,7 +1,13 @@ package gov.cms.qpp.conversion.api.model; +import static com.google.common.truth.Truth.assertThat; + +import java.util.ArrayList; +import java.util.List; + import org.junit.jupiter.api.Test; +import gov.cms.qpp.conversion.model.error.Detail; import nl.jqno.equalsverifier.EqualsVerifier; import nl.jqno.equalsverifier.Warning; @@ -15,4 +21,73 @@ void testEqualsContract() { .verify(); } + @Test + void gettersAndSetters_basicFields() { + Report r = new Report(); + + r.setProgramName("MIPS"); + r.setPracticeSiteId("PSI-1"); + r.setTimestamp(123L); + + assertThat(r.getProgramName()).isEqualTo("MIPS"); + assertThat(r.getPracticeSiteId()).isEqualTo("PSI-1"); + assertThat(r.getTimestamp()).isEqualTo(123L); + } + + @Test + void setWarnings_copiesInput_and_getWarnings_returnsDefensiveCopy() { + Report r = new Report(); + + Detail d1 = new Detail(); + Detail d2 = new Detail(); + + List input = new ArrayList<>(); + input.add(d1); + + r.setWarnings(input); + + input.add(d2); + assertThat(r.getWarnings()).containsExactly(d1); + + List returned = r.getWarnings(); + returned.clear(); + assertThat(r.getWarnings()).containsExactly(d1); + } + + @Test + void setErrors_copiesInput_and_getErrors_returnsDefensiveCopy() { + Report r = new Report(); + + Detail e1 = new Detail(); + Detail e2 = new Detail(); + + List input = new ArrayList<>(); + input.add(e1); + + r.setErrors(input); + + input.add(e2); + assertThat(r.getErrors()).containsExactly(e1); + + List returned = r.getErrors(); + returned.clear(); + assertThat(r.getErrors()).containsExactly(e1); + } + + @Test + void setWarnings_and_setErrors_null_clearsLists() { + Report r = new Report(); + + r.setWarnings(List.of(new Detail())); + r.setErrors(List.of(new Detail(), new Detail())); + + assertThat(r.getWarnings()).isNotEmpty(); + assertThat(r.getErrors()).isNotEmpty(); + + r.setWarnings(null); + r.setErrors(null); + + assertThat(r.getWarnings()).isEmpty(); + assertThat(r.getErrors()).isEmpty(); + } } diff --git a/rest-api/src/test/java/gov/cms/qpp/conversion/api/security/JwtAuthorizationFilterTest.java b/rest-api/src/test/java/gov/cms/qpp/conversion/api/security/JwtAuthorizationFilterTest.java index 649a4d327c..91027cff53 100644 --- a/rest-api/src/test/java/gov/cms/qpp/conversion/api/security/JwtAuthorizationFilterTest.java +++ b/rest-api/src/test/java/gov/cms/qpp/conversion/api/security/JwtAuthorizationFilterTest.java @@ -1,154 +1,138 @@ package gov.cms.qpp.conversion.api.security; -import com.google.common.truth.Truth; -import org.junit.Before; -import org.junit.BeforeClass; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import org.powermock.api.mockito.PowerMockito; -import org.powermock.core.classloader.annotations.PowerMockIgnore; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContext; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import gov.cms.qpp.conversion.api.helper.JwtPayloadHelper; -import gov.cms.qpp.conversion.api.helper.JwtTestHelper; +import gov.cms.qpp.test.MockitoExtension; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import java.io.IOException; -import java.util.Set; +@ExtendWith(MockitoExtension.class) +class JwtAuthorizationFilterTest { -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; + @Mock + private HttpServletRequest request; + + @Mock + private ServletResponse response; + + @Mock + private FilterChain chain; + + @AfterEach + void cleanupSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void doFilter_validTokenAndOrg_setsAuthentication_andContinuesChain() throws Exception { + JwtAuthorizationFilter filter = new JwtAuthorizationFilter(); + + String jwt = jwtWithData(Map.of( + "id", "user-123", + "name", JwtAuthorizationFilter.DEFAULT_ORG_NAME, // "cpc-test" + "orgType", "CPC" + )); + + when(request.getHeader("Authorization")).thenReturn("Bearer " + jwt); + + filter.doFilter(request, response, chain); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + assertEquals("user-123", auth.getPrincipal()); + verify(chain).doFilter(request, response); + } + + @Test + void doFilter_invalidOrg_doesNotSetAuthentication_andContinuesChain() throws Exception { + JwtAuthorizationFilter filter = new JwtAuthorizationFilter(); + + String jwt = jwtWithData(Map.of( + "id", "user-123", + "name", "not-allowed-org", + "orgType", "CPC" + )); + + when(request.getHeader("Authorization")).thenReturn("Bearer " + jwt); + + filter.doFilter(request, response, chain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(chain).doFilter(request, response); + } + + @Test + void doFilter_missingHeader_doesNotSetAuthentication_andContinuesChain() throws Exception { + JwtAuthorizationFilter filter = new JwtAuthorizationFilter(); + + when(request.getHeader("Authorization")).thenReturn(null); + + filter.doFilter(request, response, chain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(chain).doFilter(request, response); + } + + @Test + void doFilter_nonHttpRequest_doesNotSetAuthentication_andContinuesChain() throws Exception { + JwtAuthorizationFilter filter = new JwtAuthorizationFilter(); + + ServletRequest nonHttp = org.mockito.Mockito.mock(ServletRequest.class); + + filter.doFilter(nonHttp, response, chain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(chain).doFilter(nonHttp, response); + } + + /** + * Builds a compact JWT string "header.payload.signature" where header/payload are base64url JSON. + * Your filter strips the signature and parses it using parseClaimsJwt("header.payload."). + */ + private static String jwtWithData(Map data) { + String headerJson = "{\"alg\":\"none\",\"typ\":\"JWT\"}"; + + // payload contains: { "data": { ... } } + StringBuilder dataJson = new StringBuilder(); + dataJson.append("{\"data\":{"); + boolean first = true; + for (Map.Entry e : data.entrySet()) { + if (!first) dataJson.append(","); + first = false; + dataJson.append("\"").append(escape(e.getKey())).append("\":") + .append("\"").append(escape(e.getValue())).append("\""); + } + dataJson.append("}}"); + + String header = base64Url(headerJson); + String payload = base64Url(dataJson.toString()); + + // signature can be anything; filter removes it + return header + "." + payload + ".sig"; + } + + private static String base64Url(String s) { + return java.util.Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(s.getBytes(StandardCharsets.UTF_8)); + } -//Using jUnit 4 for power mock -//@RunWith(PowerMockRunner.class) -//@PrepareForTest({SecurityContextHolder.class}) -@PowerMockIgnore({"org.apache.xerces.*", "javax.xml.parsers.*", "org.xml.sax.*", "com.sun.org.apache.xerces.*", "javax.crypto.*"}) - -public class JwtAuthorizationFilterTest { - - private static final String ORG_TYPE = "registry"; - - private MockHttpServletRequest request; - private MockHttpServletResponse response; - private FilterChain filterChain; - -// @Before -// public void setUp() { -// request = new MockHttpServletRequest(); -// response = new MockHttpServletResponse(); -// filterChain = mock(FilterChain.class); -// } -// -// @Test -// public void testdoFilter() throws IOException, ServletException { -// JwtPayloadHelper payload = new JwtPayloadHelper() -// .withName(JwtAuthorizationFilter.DEFAULT_ORG_NAME) -// .withOrgType(ORG_TYPE); -// -// request.addHeader("Authorization", JwtTestHelper.createJwt(payload)); -// JwtAuthorizationFilter testJwtAuthFilter = new JwtAuthorizationFilter(); -// -// PowerMockito.mockStatic(SecurityContextHolder.class); -// SecurityContext mockSecurityContext = PowerMockito.mock(SecurityContext.class); -// -// PowerMockito.when(SecurityContextHolder.getContext()).thenReturn(mockSecurityContext); -// -// testJwtAuthFilter.doFilter(request, response, filterChain); -// -// verify(filterChain, times(1)).doFilter(any(MockHttpServletRequest.class), any(MockHttpServletResponse.class)); -// verify(SecurityContextHolder.getContext(), times(1)).setAuthentication(any(UsernamePasswordAuthenticationToken.class)); -// } -// -// @Test -// public void testdoFilterWithInvalidOrgName() throws IOException, ServletException { -// JwtPayloadHelper payload = new JwtPayloadHelper() -// .withName("invalid-name") -// .withOrgType(ORG_TYPE); -// -// request.addHeader("Authorization", JwtTestHelper.createJwt(payload)); -// JwtAuthorizationFilter testJwtAuthFilter = new JwtAuthorizationFilter(); -// -// PowerMockito.mockStatic(SecurityContextHolder.class); -// SecurityContext mockSecurityContext = PowerMockito.mock(SecurityContext.class); -// -// PowerMockito.when(SecurityContextHolder.getContext()).thenReturn(mockSecurityContext); -// -// testJwtAuthFilter.doFilter(request, response, filterChain); -// -// verify(filterChain, times(1)).doFilter(any(MockHttpServletRequest.class), any(MockHttpServletResponse.class)); -// verify(SecurityContextHolder.getContext(), times(0)).setAuthentication(any(UsernamePasswordAuthenticationToken.class)); -// } -// -// @Test -// public void testdoFilterWithNoOrgId() throws IOException, ServletException { -// JwtPayloadHelper payload = new JwtPayloadHelper() -// .withOrgType(ORG_TYPE); -// -// request.addHeader("Authorization", JwtTestHelper.createJwt(payload)); -// JwtAuthorizationFilter testJwtAuthFilter = new JwtAuthorizationFilter(); -// -// PowerMockito.mockStatic(SecurityContextHolder.class); -// SecurityContext mockSecurityContext = PowerMockito.mock(SecurityContext.class); -// -// PowerMockito.when(SecurityContextHolder.getContext()).thenReturn(mockSecurityContext); -// -// testJwtAuthFilter.doFilter(request, response, filterChain); -// -// verify(filterChain, times(1)).doFilter(any(MockHttpServletRequest.class), any(MockHttpServletResponse.class)); -// verify(SecurityContextHolder.getContext(), times(0)).setAuthentication(any(UsernamePasswordAuthenticationToken.class)); -// } -// -// @Test -// public void testdoFilterWithNoOrgType() throws IOException, ServletException { -// JwtPayloadHelper payload = new JwtPayloadHelper() -// .withName(JwtAuthorizationFilter.DEFAULT_ORG_NAME); -// -// request.addHeader("Authorization", JwtTestHelper.createJwt(payload)); -// JwtAuthorizationFilter testJwtAuthFilter = new JwtAuthorizationFilter(); -// -// PowerMockito.mockStatic(SecurityContextHolder.class); -// SecurityContext mockSecurityContext = PowerMockito.mock(SecurityContext.class); -// -// PowerMockito.when(SecurityContextHolder.getContext()).thenReturn(mockSecurityContext); -// -// testJwtAuthFilter.doFilter(request, response, filterChain); -// -// verify(filterChain, times(1)).doFilter(any(MockHttpServletRequest.class), any(MockHttpServletResponse.class)); -// verify(SecurityContextHolder.getContext(), times(0)).setAuthentication(any(UsernamePasswordAuthenticationToken.class)); -// } -// -// @Test -// public void testdoFilterWithNoHeader() throws IOException, ServletException { -// JwtAuthorizationFilter testJwtAuthFilter = new JwtAuthorizationFilter(); -// -// PowerMockito.mockStatic(SecurityContextHolder.class); -// SecurityContext mockSecurityContext = PowerMockito.mock(SecurityContext.class); -// -// PowerMockito.when(SecurityContextHolder.getContext()).thenReturn(mockSecurityContext); -// -// testJwtAuthFilter.doFilter(request, response, filterChain); -// -// verify(filterChain, times(1)).doFilter(any(MockHttpServletRequest.class), any(MockHttpServletResponse.class)); -// verify(SecurityContextHolder.getContext(), times(0)).setAuthentication(any(UsernamePasswordAuthenticationToken.class)); -// } -// -// @Test -// public void testDefaultOrgName() { -// JwtAuthorizationFilter testJwtAuthFilter = new JwtAuthorizationFilter(JwtAuthorizationFilter.DEFAULT_ORG_SET); -// Truth.assertThat(testJwtAuthFilter.orgName).contains(JwtAuthorizationFilter.DEFAULT_ORG_NAME); -// } -// -// @Test -// public void testGivenOrgName() { -// Set expected = Set.of("some org name"); -// JwtAuthorizationFilter testJwtAuthFilter = new JwtAuthorizationFilter(expected); -// Truth.assertThat(testJwtAuthFilter.orgName).isEqualTo(expected); -// } + private static String escape(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } } diff --git a/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/AdvancedApmFileServiceImplTest.java b/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/AdvancedApmFileServiceImplTest.java new file mode 100644 index 0000000000..3ded263347 --- /dev/null +++ b/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/AdvancedApmFileServiceImplTest.java @@ -0,0 +1,118 @@ +package gov.cms.qpp.conversion.api.services.internal; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gov.cms.qpp.conversion.api.exceptions.InvalidFileTypeException; +import gov.cms.qpp.conversion.api.exceptions.NoFileInDatabaseException; +import gov.cms.qpp.conversion.api.helper.AdvancedApmHelper; +import gov.cms.qpp.conversion.api.model.Constants; +import gov.cms.qpp.conversion.api.model.FileStatusUpdateRequest; +import gov.cms.qpp.conversion.api.model.Metadata; +import gov.cms.qpp.conversion.api.services.DbService; +import gov.cms.qpp.conversion.api.services.StorageService; +import gov.cms.qpp.test.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AdvancedApmFileServiceImplTest { + + @Mock + private DbService dbService; + + @Mock + private StorageService storageService; + + @InjectMocks + private AdvancedApmFileServiceImpl objectUnderTest; + + @Test + void getMetadataById_nullMetadata_throwsNoFileInDatabase() { + when(dbService.getMetadataById("id1")).thenReturn(null); + + assertThrows(NoFileInDatabaseException.class, () -> objectUnderTest.getMetadataById("id1")); + } + + @Test + void getMetadataById_notPcf_throwsInvalidFileType() { + Metadata metadata = new Metadata(); + + when(dbService.getMetadataById("id1")).thenReturn(metadata); + + assertThrows(InvalidFileTypeException.class, () -> objectUnderTest.getMetadataById("id1")); + } + + @Test + void getMetadataById_validPcf_returnsMetadata() { + Metadata metadata = new Metadata(); + metadata.setPcf("PCF_0"); + + when(dbService.getMetadataById("id1")).thenReturn(metadata); + + Metadata result = objectUnderTest.getMetadataById("id1"); + + assertThat(result).isSameInstanceAs(metadata); + } + + @Test + void updateFileStatus_processedFalse_unprocessesForCpc() { + Metadata metadata = new Metadata(); + metadata.setPcf("PCF_0"); + + when(dbService.getMetadataById("file1")).thenReturn(metadata); + when(dbService.write(any(Metadata.class))).thenReturn(CompletableFuture.completedFuture(metadata)); + + FileStatusUpdateRequest req = new FileStatusUpdateRequest(); + req.setProcessed(false); + + String msg = objectUnderTest.updateFileStatus("file1", Constants.CPC_ORG, req); + + assertThat(metadata.getCpcProcessed()).isFalse(); + assertThat(msg).isEqualTo(AdvancedApmHelper.FILE_FOUND_UNPROCESSED); + verify(dbService).write(any(Metadata.class)); + } + + @Test + void updateFileStatus_processedTrue_processesForRti() { + Metadata metadata = new Metadata(); + metadata.setPcf("PCF_0"); + + when(dbService.getMetadataById("file1")).thenReturn(metadata); + when(dbService.write(any(Metadata.class))).thenReturn(CompletableFuture.completedFuture(metadata)); + + FileStatusUpdateRequest req = new FileStatusUpdateRequest(); + req.setProcessed(true); + + String msg = objectUnderTest.updateFileStatus("file1", Constants.RTI_ORG, req); + + assertThat(metadata.getRtiProcessed()).isTrue(); + assertThat(msg).isEqualTo(AdvancedApmHelper.FILE_FOUND_PROCESSED); + verify(dbService).write(any(Metadata.class)); + } + + @Test + void updateFileStatus_unknownOrg_returnsNotFound_andDoesNotWrite() { + Metadata metadata = new Metadata(); + metadata.setPcf("PCF_0"); + + when(dbService.getMetadataById("file1")).thenReturn(metadata); + + FileStatusUpdateRequest req = new FileStatusUpdateRequest(); + req.setProcessed(true); + + String msg = objectUnderTest.updateFileStatus("file1", "SOME_OTHER_ORG", req); + + assertThat(msg).isEqualTo(AdvancedApmHelper.FILE_NOT_FOUND); + verify(dbService, never()).write(any(Metadata.class)); + } +} diff --git a/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/AuditServiceImplTest.java b/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/AuditServiceImplTest.java index 6f3e2cd75a..d99acc020a 100644 --- a/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/AuditServiceImplTest.java +++ b/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/AuditServiceImplTest.java @@ -1,220 +1,188 @@ package gov.cms.qpp.conversion.api.services.internal; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.io.InputStream; +import java.util.Collections; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +import org.springframework.core.env.Environment; + import gov.cms.qpp.conversion.ConversionReport; -import gov.cms.qpp.conversion.InputStreamSupplierSource; import gov.cms.qpp.conversion.Source; -import gov.cms.qpp.conversion.api.exceptions.UncheckedInterruptedException; -import gov.cms.qpp.conversion.api.helper.MetadataHelper; +import gov.cms.qpp.conversion.api.exceptions.AuditException; import gov.cms.qpp.conversion.api.model.Constants; import gov.cms.qpp.conversion.api.model.Metadata; import gov.cms.qpp.conversion.api.services.DbService; import gov.cms.qpp.conversion.api.services.StorageService; -import gov.cms.qpp.conversion.encode.JsonWrapper; -import gov.cms.qpp.conversion.model.Node; -import net.jodah.concurrentunit.Waiter; -import org.junit.Before; -import org.junit.jupiter.api.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.powermock.core.classloader.annotations.PowerMockIgnore; -import org.springframework.core.env.Environment; +import gov.cms.qpp.conversion.model.error.AllErrors; +import gov.cms.qpp.conversion.model.error.Detail; +import gov.cms.qpp.conversion.model.error.Error; +import gov.cms.qpp.test.MockitoExtension; -import java.io.ByteArrayInputStream; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeoutException; +@ExtendWith(MockitoExtension.class) +class AuditServiceImplTest { -import static com.google.common.truth.Truth.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.powermock.api.mockito.PowerMockito.mockStatic; - -@PowerMockIgnore({"org.apache.xerces.*", "javax.xml.parsers.*", "org.xml.sax.*", "com.sun.org.apache.xerces.*" }) -public class AuditServiceImplTest { -// private static final String AN_ID = "1234567890"; -// private static final String FILENAME = "file"; -// -// @InjectMocks -// private AuditServiceImpl underTest; -// -// @Mock -// private StorageService storageService; -// -// @Mock -// private DbService dbService; -// -// @Mock -// private ConversionReport report; -// -// @Mock -// private Environment environment; -// -// private Metadata metadata; -// private String content = "Hello"; -// private Source fileContentSource = new InputStreamSupplierSource(FILENAME, new ByteArrayInputStream(content.getBytes())); -// -// @Before -// public void before() { -// metadata = Metadata.create(); -// -// mockStatic(MetadataHelper.class); -// when(MetadataHelper.generateMetadata(any(Node.class), any(MetadataHelper.Outcome.class))) -// .thenReturn(metadata); -// doReturn(CompletableFuture.completedFuture(metadata)) -// .when(dbService).write(metadata); -// } -// -// @Test -// public void testAuditHappyPath() { -// when(environment.getProperty(Constants.NO_AUDIT_ENV_VARIABLE)).thenReturn(null); -// successfulEncodingPrep(); -// allGood(); -// underTest.success(report); -// -// assertThat(metadata.getQppLocator()).isSameInstanceAs(AN_ID); -// assertThat(metadata.getSubmissionLocator()).isSameInstanceAs(AN_ID); -// assertThat(metadata.getFileName()).isSameInstanceAs(FILENAME); -// } -// -// @Test -// public void testAuditHappyPathNoAuditIsEmpty() { -// when(environment.getProperty(Constants.NO_AUDIT_ENV_VARIABLE)).thenReturn(""); -// successfulEncodingPrep(); -// allGood(); -// underTest.success(report); -// -// assertThat(metadata.getQppLocator()).isSameInstanceAs(AN_ID); -// assertThat(metadata.getSubmissionLocator()).isSameInstanceAs(AN_ID); -// assertThat(metadata.getFileName()).isSameInstanceAs(FILENAME); -// } -// -// @Test -// public void testAuditHappyPathNoAudit() { -// when(environment.getProperty(Constants.NO_AUDIT_ENV_VARIABLE)).thenReturn("yep"); -// successfulEncodingPrep(); -// allGood(); -// underTest.success(report); -// -// verify(storageService, times(0)).store(any(String.class), any(), anyLong()); -// verify(dbService, times(0)).write(metadata); -// } -// -// @Test -// public void testAuditHappyPathWrite() { -// when(environment.getProperty(Constants.NO_AUDIT_ENV_VARIABLE)).thenReturn(null); -// successfulEncodingPrep(); -// allGood(); -// underTest.success(report); -// -// verify(dbService, times(1)).write(metadata); -// } -// -// @Test -// public void testFileUploadFailureException() throws TimeoutException, InterruptedException { -// when(environment.getProperty(Constants.NO_AUDIT_ENV_VARIABLE)).thenReturn(null); -// successfulEncodingPrep(); -// problematic(); -// Waiter waiter = new Waiter(); -// CompletableFuture future = underTest.success(report); -// -// future.whenComplete((nada, ex) -> { -// waiter.assertNull(metadata.getQppLocator()); -// waiter.assertNull(metadata.getSubmissionLocator()); -// waiter.assertTrue(ex.getCause() instanceof UncheckedInterruptedException); -// waiter.resume(); -// }); -// -// waiter.await(5000); -// } -// -// -// @Test -// public void testAuditConversionFailureHappy() { -// when(environment.getProperty(Constants.NO_AUDIT_ENV_VARIABLE)).thenReturn(null); -// errorPrep(); -// allGood(); -// underTest.failConversion(report); -// -// assertThat(metadata.getConversionErrorLocator()).isSameInstanceAs(AN_ID); -// assertThat(metadata.getSubmissionLocator()).isSameInstanceAs(AN_ID); -// } -// -// @Test -// public void testAuditConversionFailureNoAudit() { -// when(environment.getProperty(Constants.NO_AUDIT_ENV_VARIABLE)).thenReturn("yep"); -// errorPrep(); -// allGood(); -// underTest.failConversion(report); -// -// verify(storageService, times(0)).store(any(String.class), any(), anyLong()); -// verify(dbService, times(0)).write(metadata); -// } -// -// @Test -// public void testAuditQppValidationFailureHappy() { -// when(environment.getProperty(Constants.NO_AUDIT_ENV_VARIABLE)).thenReturn(null); -// successfulEncodingPrep(); -// errorPrep(); -// allGood(); -// underTest.failValidation(report); -// -// assertThat(metadata.getRawValidationErrorLocator()).isSameInstanceAs(AN_ID); -// assertThat(metadata.getValidationErrorLocator()).isSameInstanceAs(AN_ID); -// assertThat(metadata.getQppLocator()).isSameInstanceAs(AN_ID); -// assertThat(metadata.getSubmissionLocator()).isSameInstanceAs(AN_ID); -// } -// -// @Test -// public void testAuditQppValidationFailureNoAudit() { -// when(environment.getProperty(Constants.NO_AUDIT_ENV_VARIABLE)).thenReturn("yep"); -// successfulEncodingPrep(); -// errorPrep(); -// allGood(); -// underTest.failValidation(report); -// -// verify(storageService, times(0)).store(any(String.class), any(), anyLong()); -// verify(dbService, times(0)).write(metadata); -// } -// -// private void successfulEncodingPrep() { -// prepOverlap(); -// JsonWrapper wrapper = new JsonWrapper(); -// wrapper.put("meep", "mawp"); -// -// when(report.getQppSource()).thenReturn(wrapper.toSource()); -// } -// -// private void errorPrep() { -// prepOverlap(); -// -// -// when(report.getRawValidationErrorsOrEmptySource()).thenReturn(fileContentSource); -// when(report.getValidationErrorsSource()).thenReturn(fileContentSource); -// } -// -// private void prepOverlap() { -// Node node = new Node(); -// node.putValue("meep", "mawp"); -// -// when(report.getQrdaSource()).thenReturn(fileContentSource); -// when(report.getDecoded()).thenReturn(node); -// } -// -// private void allGood() { -// when(storageService.store(any(String.class), any(), anyLong())) -// .thenReturn(CompletableFuture.completedFuture(AN_ID)); -// } -// -// private void problematic() { -// when(storageService.store(any(String.class), any(), anyLong())) -// .thenReturn(CompletableFuture.supplyAsync(() -> { -// throw new UncheckedInterruptedException(new InterruptedException()); -// })); -// } + @Mock private StorageService storageService; + @Mock private DbService dbService; + @Mock private Environment environment; -} + @Mock private ConversionReport report; + + @Mock private Source qrdaSource; + @Mock private Source qppSource; + @Mock private Source validationErrorSource; + @Mock private Source rawValidationErrorSource; + + private AuditServiceImpl objectUnderTest; + + @BeforeEach + void setUp() { + objectUnderTest = new AuditServiceImpl(storageService, dbService, environment); + + // Default: auditing enabled + when(environment.getProperty(Constants.NO_AUDIT_ENV_VARIABLE)).thenReturn(null); + + // Keep MetadataHelper.generateMetadata(report.getDecoded(), outcome) simple (node = null) + when(report.getDecoded()).thenReturn(null); + + when(report.getPurpose()).thenReturn("TestPurpose"); + + when(report.getQrdaSource()).thenReturn(qrdaSource); + when(report.getQppSource()).thenReturn(qppSource); + when(report.getValidationErrorsSource()).thenReturn(validationErrorSource); + when(report.getRawValidationErrorsOrEmptySource()).thenReturn(rawValidationErrorSource); + + when(report.getReportDetails()).thenReturn(null); + when(qrdaSource.getName()).thenReturn("qrda.xml"); + // sizes are required by storeContent(...) + when(qrdaSource.getSize()).thenReturn(10L); + when(qppSource.getSize()).thenReturn(20L); + when(validationErrorSource.getSize()).thenReturn(30L); + when(rawValidationErrorSource.getSize()).thenReturn(40L); + + // dbService.write returns whatever metadata it was asked to save + when(dbService.write(any(Metadata.class))) + .thenAnswer(inv -> CompletableFuture.completedFuture(inv.getArgument(0))); + } + + @Test + void success_noAuditEnabled_returnsNull_andDoesNothing() { + when(environment.getProperty(Constants.NO_AUDIT_ENV_VARIABLE)).thenReturn("true"); + + assertThat(objectUnderTest.success(report)).isNull(); + + verifyNoInteractions(storageService); + verifyNoInteractions(dbService); + } + + @Test + void success_happyPath_setsLocators_andWritesMetadata() { + when(storageService.store(anyString(), anySupplier(), anyLong())) + .thenReturn( + CompletableFuture.completedFuture("submission-loc"), + CompletableFuture.completedFuture("qpp-loc") + ); + + Metadata result = objectUnderTest.success(report).join(); + + assertThat(result.getSubmissionLocator()).isEqualTo("submission-loc"); + assertThat(result.getQppLocator()).isEqualTo("qpp-loc"); + assertThat(result.getFileName()).isEqualTo("qrda.xml"); + assertThat(result.getPurpose()).isEqualTo("TestPurpose"); + + verify(storageService, times(2)).store(anyString(), anySupplier(), anyLong()); + verify(dbService, times(1)).write(any(Metadata.class)); + } + + @Test + void failConversion_happyPath_setsConversionErrorLocator_andSubmissionLocator() { + when(storageService.store(anyString(), anySupplier(), anyLong())) + .thenReturn( + CompletableFuture.completedFuture("conversion-error-loc"), + CompletableFuture.completedFuture("submission-loc") + ); + + CompletableFuture future = objectUnderTest.failConversion(report); + future.join(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Metadata.class); + verify(dbService).write(captor.capture()); + + Metadata saved = captor.getValue(); + assertThat(saved.getConversionErrorLocator()).isEqualTo("conversion-error-loc"); + assertThat(saved.getSubmissionLocator()).isEqualTo("submission-loc"); + assertThat(saved.getFileName()).isEqualTo("qrda.xml"); + assertThat(saved.getPurpose()).isEqualTo("TestPurpose"); + } + + @Test + void failValidation_happyPath_setsAllLocators() { + when(storageService.store(anyString(), anySupplier(), anyLong())) + .thenReturn( + CompletableFuture.completedFuture("raw-validation-loc"), + CompletableFuture.completedFuture("validation-loc"), + CompletableFuture.completedFuture("qpp-loc"), + CompletableFuture.completedFuture("submission-loc") + ); + + CompletableFuture future = objectUnderTest.failValidation(report); + future.join(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Metadata.class); + verify(dbService).write(captor.capture()); + + Metadata saved = captor.getValue(); + assertThat(saved.getRawValidationErrorLocator()).isEqualTo("raw-validation-loc"); + assertThat(saved.getValidationErrorLocator()).isEqualTo("validation-loc"); + assertThat(saved.getQppLocator()).isEqualTo("qpp-loc"); + assertThat(saved.getSubmissionLocator()).isEqualTo("submission-loc"); + } + + @Test + void success_whenStoreFails_joinThrowsCompletionException_withRuntimeCause_andDoesNotWrite() { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new RuntimeException("boom")); + + when(storageService.store(anyString(), anySupplier(), anyLong())) + .thenReturn( + failed, + CompletableFuture.completedFuture("qpp-loc") + ); + + CompletionException ex = assertThrows( + CompletionException.class, + () -> objectUnderTest.success(report).join() + ); + + // Primary cause is the original store failure (matches what you observed) + assertThat(ex.getCause()).isInstanceOf(RuntimeException.class); + assertThat(ex.getCause()).hasMessageThat().contains("boom"); + + // persist() threw AuditException, so db write should not happen + verify(dbService, never()).write(any(Metadata.class)); + + // Optional (don’t make the test brittle): AuditException may appear as suppressed depending on JDK behavior. + // If you want to assert it, uncomment: + // assertThat(java.util.Arrays.stream(ex.getSuppressed()).anyMatch(t -> t instanceof AuditException)).isTrue(); + } + + @SuppressWarnings("unchecked") + private static Supplier anySupplier() { + return (Supplier) org.mockito.ArgumentMatchers.any(Supplier.class); + } +} diff --git a/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/QrdaServiceImplTest.java b/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/QrdaServiceImplTest.java index f1d38c2dc3..97ba917078 100644 --- a/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/QrdaServiceImplTest.java +++ b/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/QrdaServiceImplTest.java @@ -1,12 +1,14 @@ package gov.cms.qpp.conversion.api.services.internal; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Spy; import gov.cms.qpp.conversion.ConversionReport; import gov.cms.qpp.conversion.Converter; @@ -20,108 +22,111 @@ import gov.cms.qpp.conversion.model.error.TransformException; import gov.cms.qpp.test.MockitoExtension; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; - -import static com.google.common.truth.Truth.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - @ExtendWith(MockitoExtension.class) class QrdaServiceImplTest { - private static final Source MOCK_SUCCESS_QRDA_SOURCE = - new InputStreamSupplierSource("Good Qrda", new ByteArrayInputStream("Good Qrda".getBytes())); - private static final Source MOCK_ERROR_QRDA_SOURCE = - new InputStreamSupplierSource("Error Qrda", new ByteArrayInputStream("Error Qrda".getBytes())); - private static final String KEY = "key"; - private static final String MOCK_SUCCESS_QPP_STRING = "Good Qpp"; - private static final String MOCK_ERROR_SOURCE_IDENTIFIER = "Error Identifier"; - private static final Path VALIDATION_JSON_FILE_PATH = Path.of("src/test/resources/testCpcPlusValidationFile.json"); - private static final Path VALIDATION_APM_FILE_PATH = Path.of("src/test/resources/test_apm_entity_ids.json"); - private InputStream MOCK_INPUT_STREAM; - private InputStream MOCK_APM_INPUT_STREAM; - - @Spy - @InjectMocks - private QrdaServiceImpl objectUnderTest; - - @Mock - private StorageService storageService; - - @BeforeEach - void mockConverter() throws IOException { - MOCK_INPUT_STREAM = Files.newInputStream(VALIDATION_JSON_FILE_PATH); - MOCK_APM_INPUT_STREAM = Files.newInputStream(VALIDATION_APM_FILE_PATH); - Converter success = successConverter(); - when(objectUnderTest.initConverter(MOCK_SUCCESS_QRDA_SOURCE)) - .thenReturn(success); - - when(objectUnderTest.retrieveCpcPlusValidationFile()) - .thenReturn(MOCK_INPUT_STREAM); - - when(objectUnderTest.retrieveApmValidationFile(Constants.CPC_PLUS_APM_FILE_NAME_KEY)) - .thenReturn(MOCK_APM_INPUT_STREAM); - - Converter error = errorConverter(); - when(objectUnderTest.initConverter(MOCK_ERROR_QRDA_SOURCE)) - .thenReturn(error); - } + private static final Source GOOD_SOURCE = + new InputStreamSupplierSource("Good Qrda", new ByteArrayInputStream("".getBytes())); + private static final Source ERROR_SOURCE = + new InputStreamSupplierSource("Error Qrda", new ByteArrayInputStream("".getBytes())); - @AfterEach - void tearDown() throws IOException { - MOCK_APM_INPUT_STREAM.close(); - MOCK_APM_INPUT_STREAM.close(); - } + private static final String KEY = "key"; + private static final String GOOD_QPP = "Good Qpp"; @Test - void testConvertQrda3ToQppSuccess() { - JsonWrapper qpp = objectUnderTest.convertQrda3ToQpp(MOCK_SUCCESS_QRDA_SOURCE).getEncodedWithMetadata(); - assertThat(qpp.getString(KEY)).isSameInstanceAs(MOCK_SUCCESS_QPP_STRING); - } + void convertQrda3ToQpp_success_callsTransform_andReturnsReport() { + StorageService storage = mock(StorageService.class); + QrdaServiceImpl service = spy(new QrdaServiceImpl(storage)); -// @Test -// void testConvertQrda3ToQppError() { -// TransformException exception = assertThrows(TransformException.class, -// () -> objectUnderTest.convertQrda3ToQpp(MOCK_ERROR_QRDA_SOURCE)); -// AllErrors allErrors = exception.getDetails(); -// assertThat(allErrors.getErrors().get(0).getSourceIdentifier()).isSameInstanceAs(MOCK_ERROR_SOURCE_IDENTIFIER); -// } + Converter converter = mock(Converter.class); + ConversionReport report = mock(ConversionReport.class); - @Test - void testPostConstructForCoverage() { - objectUnderTest.preloadMeasureConfigs(); - } + JsonWrapper wrapper = new JsonWrapper(); + wrapper.put(KEY, GOOD_QPP); - private Converter successConverter() { - Converter mockConverter = mock(Converter.class); + when(service.initConverter(GOOD_SOURCE)).thenReturn(converter); - JsonWrapper qpp = new JsonWrapper(); - qpp.put(KEY, MOCK_SUCCESS_QPP_STRING); + when(converter.transform()).thenReturn(wrapper); - ConversionReport report = mock(ConversionReport.class); + when(converter.getReport()).thenReturn(report); + when(report.getEncodedWithMetadata()).thenReturn(wrapper); - when(report.getEncodedWithMetadata()).thenReturn(qpp); - when(mockConverter.getReport()).thenReturn(report); + ConversionReport result = service.convertQrda3ToQpp(GOOD_SOURCE); - return mockConverter; + verify(converter).transform(); + verify(converter).getReport(); + assertThat(result.getEncodedWithMetadata().getString(KEY)).isEqualTo(GOOD_QPP); } - private Converter errorConverter() { - Converter mockConverter = mock(Converter.class); + @Test + void convertQrda3ToQpp_whenTransformThrows_propagatesTransformException() { + StorageService storage = mock(StorageService.class); + QrdaServiceImpl service = spy(new QrdaServiceImpl(storage)); + + Converter converter = mock(Converter.class); + AllErrors allErrors = new AllErrors(); - allErrors.addError(new Error(MOCK_ERROR_SOURCE_IDENTIFIER, null)); + allErrors.addError(new Error("Error Identifier", null)); ConversionReport report = mock(ConversionReport.class); when(report.getReportDetails()).thenReturn(allErrors); - TransformException transformException = new TransformException("mock problem", new NullPointerException(), report); - when(mockConverter.transform()).thenThrow(transformException); + TransformException boom = new TransformException("mock problem", new NullPointerException(), report); + + when(service.initConverter(ERROR_SOURCE)).thenReturn(converter); + when(converter.transform()).thenThrow(boom); + + assertThrows(TransformException.class, () -> service.convertQrda3ToQpp(ERROR_SOURCE)); + } + + @Test + void retrieveCpcPlusValidationFile_delegatesToStorageService() { + StorageService storage = mock(StorageService.class); + QrdaServiceImpl service = new QrdaServiceImpl(storage); + + InputStream expected = new ByteArrayInputStream("x".getBytes()); + when(storage.getCpcPlusValidationFile()).thenReturn(expected); + + InputStream actual = service.retrieveCpcPlusValidationFile(); + + assertThat(actual).isSameInstanceAs(expected); + verify(storage).getCpcPlusValidationFile(); + } + + @Test + void retrieveApmValidationFile_delegatesToStorageService() { + StorageService storage = mock(StorageService.class); + QrdaServiceImpl service = new QrdaServiceImpl(storage); + + InputStream expected = new ByteArrayInputStream("{}".getBytes()); + when(storage.getApmValidationFile("file.json")).thenReturn(expected); + + InputStream actual = service.retrieveApmValidationFile("file.json"); + + assertThat(actual).isSameInstanceAs(expected); + verify(storage).getApmValidationFile("file.json"); + } + + @Test + void loadApmData_thenInitConverter_fetchesApmFile_once_dueToMemoization() { + StorageService storage = mock(StorageService.class); + QrdaServiceImpl service = new QrdaServiceImpl(storage); + + when(storage.getApmValidationFile(Constants.PCF_APM_FILE_NAME_KEY)).thenReturn(null); + + service.loadApmData(); + + assertThat(service.initConverter(GOOD_SOURCE)).isNotNull(); + assertThat(service.initConverter(GOOD_SOURCE)).isNotNull(); + + verify(storage, times(1)).getApmValidationFile(Constants.PCF_APM_FILE_NAME_KEY); + } + + @Test + void preloadMeasureConfigs_forCoverage() { + StorageService storage = mock(StorageService.class); + QrdaServiceImpl service = new QrdaServiceImpl(storage); - return mockConverter; + service.preloadMeasureConfigs(); } } diff --git a/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/ValidationServiceImplTest.java b/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/ValidationServiceImplTest.java index 03819c4278..7db413a331 100644 --- a/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/ValidationServiceImplTest.java +++ b/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/ValidationServiceImplTest.java @@ -217,14 +217,21 @@ void testCheckForValidationUrlVariableLoggingIfAbsent() { Mockito.verify(objectUnderTest, Mockito.times(1)).apiLog(Constants.VALIDATION_URL_ENV_VARIABLE + " is unset"); } -// @Test -// void testInvalidSubmissionResponseJsonPath() throws IOException { -// pathToSubmissionError = Path.of("src/test/resources/invalidSubmissionErrorFixture.json"); -// String errorJson = FileUtils.readFileToString(pathToSubmissionError.toFile(), StandardCharsets.UTF_8); -// convertedErrors = service.convertQppValidationErrorsToQrda(errorJson, qppWrapper); -// -// convertedErrors.getErrors().stream().flatMap(error -> error.getDetails().stream()) -// .map(Detail::getLocation).map(Location::getPath) -// .forEach(path -> assertThat(path, is(ValidationServiceImpl.UNABLE_PROVIDE_XPATH))); -// } + @Test + void testNoHandlingErrorHandlerDoesNothing() throws IOException { + try { + Class innerClass = Class.forName("gov.cms.qpp.conversion.api.services.internal.ValidationServiceImpl$NoHandlingErrorHandler"); + java.lang.reflect.Constructor constructor = innerClass.getDeclaredConstructor(); + constructor.setAccessible(true); + Object errorHandler = constructor.newInstance(); + + java.lang.reflect.Method method = innerClass.getDeclaredMethod("handleError", org.springframework.http.client.ClientHttpResponse.class); + method.setAccessible(true); + + // This should not throw an exception + method.invoke(errorHandler, mock(org.springframework.http.client.ClientHttpResponse.class)); + } catch (Exception e) { + throw new RuntimeException("Failed to test private NoHandlingErrorHandler", e); + } + } } diff --git a/test-coverage/pom.xml b/test-coverage/pom.xml index af090d14bb..3597f0b0c1 100644 --- a/test-coverage/pom.xml +++ b/test-coverage/pom.xml @@ -53,6 +53,12 @@ org.jacoco jacoco-maven-plugin 0.8.7 + + + **/gov/cms/qpp/conversion/api/config/** + **/gov/cms/qpp/conversion/api/controllers/v1/** + + report-aggregate From 1343811be45054df57f0d36e3404e700bc3f8b45 Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Fri, 6 Feb 2026 09:40:01 -0600 Subject: [PATCH 02/19] feat: QPPA-11206 remove unused security file --- .../conversion/api/config/SecurityConfig.java | 52 ------------------- 1 file changed, 52 deletions(-) delete mode 100644 rest-api/src/main/java/gov/cms/qpp/conversion/api/config/SecurityConfig.java diff --git a/rest-api/src/main/java/gov/cms/qpp/conversion/api/config/SecurityConfig.java b/rest-api/src/main/java/gov/cms/qpp/conversion/api/config/SecurityConfig.java deleted file mode 100644 index 23be0533da..0000000000 --- a/rest-api/src/main/java/gov/cms/qpp/conversion/api/config/SecurityConfig.java +++ /dev/null @@ -1,52 +0,0 @@ -package gov.cms.qpp.conversion.api.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; -import org.springframework.web.bind.annotation.CrossOrigin; - -import gov.cms.qpp.conversion.api.security.JwtAuthorizationFilter; - -import java.util.Set; - -/** - * Web Security Configuration - */ -@Configuration -@EnableWebSecurity -@CrossOrigin(origins="*") -@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true) -public class SecurityConfig { - - private static final String PCF_WILDCARD = "/pcf/**"; - - @Value("${ORG_NAME:" + JwtAuthorizationFilter.DEFAULT_ORG_NAME + "}") - protected String orgName; - - @Value("${RTI_ORG_NAME:" + JwtAuthorizationFilter.DEFAULT_RTI_ORG + "}") - protected String rtiOrgName; - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.securityMatcher(PCF_WILDCARD) - .authorizeRequests() - .anyRequest().authenticated() - .and() - .csrf(csrf -> csrf.disable()) - .addFilterAt(new JwtAuthorizationFilter(Set.of(orgName, rtiOrgName)), BasicAuthenticationFilter.class) - .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .headers(headers -> headers - .contentSecurityPolicy(csp -> csp - .policyDirectives("script-src 'self'") - ) - ); - - return http.build(); - } -} From ac3d8fb24ae91ba3ca4c9b25eb8fd52194937ed4 Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Fri, 6 Feb 2026 11:59:19 -0600 Subject: [PATCH 03/19] feat: QPPA-11206 Clean up securityConfig file --- .../qpp/conversion/api/config/SecurityConfig.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 rest-api/src/main/java/gov/cms/qpp/conversion/api/config/SecurityConfig.java diff --git a/rest-api/src/main/java/gov/cms/qpp/conversion/api/config/SecurityConfig.java b/rest-api/src/main/java/gov/cms/qpp/conversion/api/config/SecurityConfig.java new file mode 100644 index 0000000000..179d9a21c0 --- /dev/null +++ b/rest-api/src/main/java/gov/cms/qpp/conversion/api/config/SecurityConfig.java @@ -0,0 +1,13 @@ +package gov.cms.qpp.conversion.api.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; + +/** + * Web Security Configuration + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true) +public class SecurityConfig { } \ No newline at end of file From 776b63835d41273920bf5d8a78025415a73d4a94 Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Fri, 6 Feb 2026 13:29:29 -0600 Subject: [PATCH 04/19] feat: QPPA-11206 revert securityConfig removal --- .../conversion/api/config/SecurityConfig.java | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/rest-api/src/main/java/gov/cms/qpp/conversion/api/config/SecurityConfig.java b/rest-api/src/main/java/gov/cms/qpp/conversion/api/config/SecurityConfig.java index 179d9a21c0..b0d1473903 100644 --- a/rest-api/src/main/java/gov/cms/qpp/conversion/api/config/SecurityConfig.java +++ b/rest-api/src/main/java/gov/cms/qpp/conversion/api/config/SecurityConfig.java @@ -1,13 +1,52 @@ package gov.cms.qpp.conversion.api.config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.web.bind.annotation.CrossOrigin; + +import gov.cms.qpp.conversion.api.security.JwtAuthorizationFilter; + +import java.util.Set; /** * Web Security Configuration */ @Configuration @EnableWebSecurity +@CrossOrigin(origins="*") @EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true) -public class SecurityConfig { } \ No newline at end of file +public class SecurityConfig { + + private static final String PCF_WILDCARD = "/pcf/**"; + + @Value("${ORG_NAME:" + JwtAuthorizationFilter.DEFAULT_ORG_NAME + "}") + protected String orgName; + + @Value("${RTI_ORG_NAME:" + JwtAuthorizationFilter.DEFAULT_RTI_ORG + "}") + protected String rtiOrgName; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.securityMatcher(PCF_WILDCARD) + .authorizeRequests() + .anyRequest().authenticated() + .and() + .csrf(csrf -> csrf.disable()) + .addFilterAt(new JwtAuthorizationFilter(Set.of(orgName, rtiOrgName)), BasicAuthenticationFilter.class) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .headers(headers -> headers + .contentSecurityPolicy(csp -> csp + .policyDirectives("script-src 'self'") + ) + ); + + return http.build(); + } +} \ No newline at end of file From b9700d50c34d284e26d7db92ee1b06a1c33903e8 Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Tue, 10 Feb 2026 10:57:40 -0600 Subject: [PATCH 05/19] feat: QPPA-11258 Update Release Process --- .github/workflows/codebuild-trigger-dev.yml | 2 +- .../workflows/codebuild-trigger-devpre.yml | 2 +- .github/workflows/codebuild-trigger-impl.yml | 4 +- .github/workflows/codebuild-trigger-prod.yml | 2 +- .github/workflows/draft-new-release.yml | 87 +++++++++++++++++++ .github/workflows/draft-release.yml | 32 ------- .github/workflows/publish-release.yml | 67 ++++++++++++++ 7 files changed, 159 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/draft-new-release.yml delete mode 100644 .github/workflows/draft-release.yml create mode 100644 .github/workflows/publish-release.yml diff --git a/.github/workflows/codebuild-trigger-dev.yml b/.github/workflows/codebuild-trigger-dev.yml index b417a93280..17a361fd36 100644 --- a/.github/workflows/codebuild-trigger-dev.yml +++ b/.github/workflows/codebuild-trigger-dev.yml @@ -5,7 +5,7 @@ on: jobs: build: - name: conversion tool codebuil job + name: conversion tool codebuild job permissions: id-token: write contents: read diff --git a/.github/workflows/codebuild-trigger-devpre.yml b/.github/workflows/codebuild-trigger-devpre.yml index 661d34f4ac..3593f6f60e 100644 --- a/.github/workflows/codebuild-trigger-devpre.yml +++ b/.github/workflows/codebuild-trigger-devpre.yml @@ -5,7 +5,7 @@ on: jobs: build: - name: conversion tool codebuil job + name: conversion tool codebuild job permissions: id-token: write contents: read diff --git a/.github/workflows/codebuild-trigger-impl.yml b/.github/workflows/codebuild-trigger-impl.yml index ad89dd6ea9..387766f521 100644 --- a/.github/workflows/codebuild-trigger-impl.yml +++ b/.github/workflows/codebuild-trigger-impl.yml @@ -5,7 +5,7 @@ on: jobs: build: - name: conversion tool codebuil job + name: conversion tool codebuild job permissions: id-token: write contents: read @@ -25,4 +25,4 @@ jobs: - name: Execute ssh command run: | - aws codebuild start-build --project-name "qppa-conversion-tool-impl" --source-version "${GITHUB_REF##*/}" \ No newline at end of file + aws codebuild start-build --project-name "qppa-conversion-tool-impl" --source-version "release/${GITHUB_REF##*/}" \ No newline at end of file diff --git a/.github/workflows/codebuild-trigger-prod.yml b/.github/workflows/codebuild-trigger-prod.yml index a1b6453b96..f45b80ddc9 100644 --- a/.github/workflows/codebuild-trigger-prod.yml +++ b/.github/workflows/codebuild-trigger-prod.yml @@ -5,7 +5,7 @@ on: jobs: build: - name: conversion tool codebuil job + name: conversion tool codebuild job permissions: id-token: write contents: read diff --git a/.github/workflows/draft-new-release.yml b/.github/workflows/draft-new-release.yml new file mode 100644 index 0000000000..d13197fac4 --- /dev/null +++ b/.github/workflows/draft-new-release.yml @@ -0,0 +1,87 @@ +name: "Draft new release" + +on: + workflow_dispatch: + inputs: + version: + description: "The version you want to release." + required: true + +jobs: + draft-new-release: + name: "Draft a new release" + runs-on: ubuntu-latest + if: github.ref_name == 'develop' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + outputs: + commitSha: ${{ steps.make-commit.outputs.commit }} + steps: + - uses: actions/checkout@v4 + + - name: Create release branch + run: | + git checkout -b release/v${{ github.event.inputs.version }}; + + - name: Create draft release + uses: release-drafter/release-drafter@v6 + with: + config-name: release-draft.yml + version: v${{ github.event.inputs.version }} + tag: v${{ github.event.inputs.version }} + name: v${{ github.event.inputs.version }} + prerelease: false + publish: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: "corretto" + cache: 'maven' + + - name: Bump version in pom.xml files + run: | + + # Update all pom.xml files using Maven versions plugin + echo "Updating version in all pom.xml files to ${{ github.event.inputs.version }}-RELEASE..." + mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false + echo "Version update complete." + + # In order to make a commit, we need to initialize a user. + # You may choose to write something less generic here if you want, it doesn't matter functionality-wise. + - name: Initialize mandatory git config + run: | + git config user.name "GitHub actions" + git config user.email noreply@github.com + + - name: Fetch commit hash + id: make-commit + run: echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + + - name: Commit changelog and manifest files + run: | + git add '**/pom.xml' pom.xml + git commit --message "Prepare release v${{ github.event.inputs.version }}" + + - name: Push new branch + run: | + git push origin release/v${{ github.event.inputs.version }} + + - name: Create pull request to master branch + uses: thomaseizinger/create-pull-request@1.4.0 + with: + github_token: ${{ secrets.ACTIONS_NICHOLAS_PAT }} # MUST use a PAT here in order to trigger the next workflow: CodeBuild Trigger; see https://docs.github.com/en/actions/using-workflows/triggering-a-workflow + head: release/v${{ github.event.inputs.version }} + base: master + draft: true + title: Deploy Release version v${{ github.event.inputs.version }} to Prod + body: | + Hi @${{ github.actor }}! + + This PR was created in response to a manual trigger of the release workflow here: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}. + The changelog was updated and the version was bumped in the manifest files in this commit: ${{ steps.make-commit.outputs.commit }}. + + Merging this PR will create a GitHub release. diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml deleted file mode 100644 index 281637537b..0000000000 --- a/.github/workflows/draft-release.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Draft Release Notes - -on: create - -jobs: - - release_draft: - if: github.ref == 'refs/heads/release/' - name: Create a new release - runs-on: ubuntu-latest - steps: - - - name: Checkout codebase - uses: actions/checkout@v2 - with: - fetch-depth: 2 - - - name: Detect new tag version from pom.xml - id: package-version-prod-impl - uses: salsify/action-detect-and-tag-new-version@68bbe8670f415d304e02942186441939c4692aa6 # pin@v1.0.3 - with: - version-command: | - mvn org.apache.maven.plugins:maven-help-plugin:3.4.0:evaluate -Dexpression=project.version | grep -v '\[' - - - name: Draft release notes - uses: release-drafter/release-drafter@06d4616a80cd7c09ea3cf12214165ad6c1859e67 #v5.11 - with: - config-name: release-draft.yml - version: v${{ steps.package-version.outputs.current-version }} - tag: v${{ steps.package-version.outputs.current-version }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000000..70475584de --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,67 @@ +name: Publish GitHub Release and Backfill + +on: + push: + branches: + - master + +jobs: + publish_and_backfill: + name: Publish GitHub release and create backfill PR + runs-on: ubuntu-latest + steps: + + - name: Checkout codebase + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get latest draft release + id: get_release + run: | + # Get the latest draft release + RELEASE_DATA=$(gh release list --limit 1 --json isDraft,tagName,name | jq -r '.[0]') + IS_DRAFT=$(echo "$RELEASE_DATA" | jq -r '.isDraft') + TAG_NAME=$(echo "$RELEASE_DATA" | jq -r '.tagName') + + echo "is_draft=${IS_DRAFT}" >> $GITHUB_OUTPUT + echo "tag_name=${TAG_NAME}" >> $GITHUB_OUTPUT + echo "Found release: ${TAG_NAME} (draft: ${IS_DRAFT})" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish draft release + if: steps.get_release.outputs.is_draft == 'true' + run: | + gh release edit ${{ steps.get_release.outputs.tag_name }} --draft=false --latest + echo "Published release ${{ steps.get_release.outputs.tag_name }} as latest" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create backfill PR to develop + run: | + # Check if a PR already exists + EXISTING_PR=$(gh pr list --base develop --head master --json number --jq '.[0].number' || echo "") + + if [ -n "$EXISTING_PR" ]; then + echo "PR #${EXISTING_PR} already exists for master -> develop" + exit 0 + fi + + # Create the backfill PR + gh pr create \ + --base develop \ + --head master \ + --title "Backfill master into develop" \ + --body "This PR backfills changes from master into develop after production deployment. + + ## Changes + This includes all changes that were merged to master. + + ## Notes + - Review for any conflicts + - Merge after verifying all changes are appropriate for develop branch" + + echo "Created backfill PR from master to develop" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 33cdf4f4dade6e2c20970ab2e00ae2c9488e96e8 Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Tue, 10 Feb 2026 13:00:36 -0600 Subject: [PATCH 06/19] feat: QPPA-11258 Update PR template --- .github/PULL_REQUEST_TEMPLATE.md | 79 ++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 625c2e889b..1230bc4b4f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,15 +1,64 @@ -### Information -- Fixes #_. -- JIRA story _. - -### Changes proposed in this PR: -- *(Please list what you changed.)* -- -- - -### Checklist -- [ ] All JUnit tests pass (`mvn clean verify`). -- [ ] New unit tests written to cover new functionality. -- [ ] Added and updated JavaDocs for non-test classes and methods. -- [ ] No local design debt. Do you feel that something is "ugly" after your changes? -- [ ] Updated documentation (`README.md`, etc.) depending if the changes require it. + + +## Related Tickets & Documents + +https://jira.cms.gov/browse/QPPA-XXXX + +--- + +## Description +See ticket description. + +--- +## What type of PR is this? + +- [ ] πŸ• Feature +- [ ] πŸ› Bug Fix +- [ ] πŸ“ Documentation Update +- [ ] πŸ§‘β€πŸ’» Code Refactor +- [ ] πŸ”₯ Performance Improvements +- [ ] βœ… Test +- [ ] πŸ€– Build +- [ ] πŸ” CI +- [ ] πŸ“¦ Chore +- [ ] ⏩ Revert + +--- + +## Added tests? +- [ ] πŸ‘ yes +- [ ] πŸ™… no, because they aren't needed +- [ ] πŸ™‹ no, because I need help +--- + +## Added to documentation? +- [ ] πŸ“œ README.md +- [ ] πŸ““ Confluence +- [ ] πŸ™… no documentation needed + +--- +### [ βŒ₯ Optional ] Are there any post-deployment tasks we need to perform? +--- + + +### [ βŒ₯ Optional] What gif best describes this PR or how it makes you feel? + +* For Chromium users: https://chrome.google.com/webstore/detail/gifs-for-github/dkgjnpbipbdaoaadbdhpiokaemhlphep +* For Firefox users: https://addons.mozilla.org/en-US/firefox/addon/gifs-for-github +* After installing the extension, click on the "GIF" button in the comment box and search for a gif that describes your PR! + + + From f512682ff1084c039930ca91f06434e780db5ad5 Mon Sep 17 00:00:00 2001 From: John Manack Date: Mon, 16 Feb 2026 10:18:08 -0500 Subject: [PATCH 07/19] feat: QPPA-11251 adds closure to s3 connections to mitigate memory leak issues --- .../services/internal/StorageServiceImpl.java | 39 +++++++++++--- .../internal/StorageServiceImplTest.java | 52 ++++++++++++++----- 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/rest-api/src/main/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImpl.java b/rest-api/src/main/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImpl.java index bd06213d3e..ee28d988e7 100644 --- a/rest-api/src/main/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImpl.java +++ b/rest-api/src/main/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImpl.java @@ -20,6 +20,8 @@ import gov.cms.qpp.conversion.api.model.Constants; import gov.cms.qpp.conversion.api.services.StorageService; +import java.io.FilterInputStream; +import java.io.IOException; import java.io.InputStream; import java.util.Objects; import java.util.concurrent.CompletableFuture; @@ -99,12 +101,11 @@ public InputStream getFileByLocationId(String fileLocationId) { API_LOG.info("Retrieving file {} from bucket {}", fileLocationId, bucketName); GetObjectRequest getObjectRequest = new GetObjectRequest(bucketName, fileLocationId); - - S3Object s3Object = amazonS3.getObject(getObjectRequest); + InputStream objectContent = retrieveManagedContentStream(getObjectRequest); API_LOG.info("Successfully retrieved file {} from S3 bucket {}", getObjectRequest.getKey(), getObjectRequest.getBucketName()); - return s3Object.getObjectContent(); + return objectContent; } /** @@ -123,9 +124,7 @@ public InputStream getCpcPlusValidationFile() { API_LOG.info("Retrieving CPC+ validation file from bucket {}", bucketName); GetObjectRequest getObjectRequest = new GetObjectRequest(bucketName, key); - S3Object s3Object = amazonS3.getObject(getObjectRequest); - - return s3Object.getObjectContent(); + return retrieveManagedContentStream(getObjectRequest); } /** @@ -143,9 +142,15 @@ public InputStream getApmValidationFile(String fileName) { API_LOG.info("Retrieving APM validation file from bucket {}", bucketName); GetObjectRequest getObjectRequest = new GetObjectRequest(bucketName, fileName); - S3Object s3Object = amazonS3.getObject(getObjectRequest); + return retrieveManagedContentStream(getObjectRequest); + } - return s3Object.getObjectContent(); + /** + * Provides an {@link InputStream} whose close also closes the backing {@link S3Object}. + */ + private InputStream retrieveManagedContentStream(GetObjectRequest getObjectRequest) { + S3Object s3Object = amazonS3.getObject(getObjectRequest); + return new ManagedS3InputStream(s3Object); } /** @@ -176,4 +181,22 @@ protected String asynchronousAction(Supplier objectToActOn) { protected String getActionName() { return "Write to Storage"; } + + private static final class ManagedS3InputStream extends FilterInputStream { + private final S3Object s3Object; + + ManagedS3InputStream(S3Object s3Object) { + super(s3Object.getObjectContent()); + this.s3Object = s3Object; + } + + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + s3Object.close(); + } + } + } } \ No newline at end of file diff --git a/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImplTest.java b/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImplTest.java index 0a0b8d51ab..d4e8e7341e 100644 --- a/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImplTest.java +++ b/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImplTest.java @@ -12,7 +12,10 @@ import static org.mockito.Mockito.when; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -144,7 +147,8 @@ void noBucket() { @Test void envVariablesPresent() { S3Object s3ObjectMock = mock(S3Object.class); - s3ObjectMock.setObjectContent(new ByteArrayInputStream("1234".getBytes())); + S3ObjectInputStream objectContent = new S3ObjectInputStream(new ByteArrayInputStream("1234".getBytes(StandardCharsets.UTF_8)), null); + when(s3ObjectMock.getObjectContent()).thenReturn(objectContent); Mockito.when(amazonS3Client.getObject(any(GetObjectRequest.class))).thenReturn(s3ObjectMock); Mockito.when(environment.getProperty(Constants.BUCKET_NAME_ENV_VARIABLE)).thenReturn("meep"); underTest.getFileByLocationId("meep"); @@ -197,31 +201,53 @@ void test_getCpcPlusValidationFile_NPE() { } @Test - void test_getCpcPlusValidationFile() { - S3ObjectInputStream expected = new S3ObjectInputStream(null, null); + void test_getCpcPlusValidationFile() throws IOException { + byte[] expectedBytes = "Mock Contents".getBytes(StandardCharsets.UTF_8); + S3ObjectInputStream expectedStream = new S3ObjectInputStream(new ByteArrayInputStream(expectedBytes), null); S3Object mockS3Obj = mock(S3Object.class); - Mockito.when(mockS3Obj.getObjectContent()).thenReturn(expected); + Mockito.when(mockS3Obj.getObjectContent()).thenReturn(expectedStream); Mockito.when(environment.getProperty(Constants.CPC_PLUS_BUCKET_NAME_VARIABLE)).thenReturn("Mock_Bucket"); Mockito.when(environment.getProperty(Constants.CPC_PLUS_FILENAME_VARIABLE)).thenReturn("Mock_Filename"); - Mockito.when(amazonS3Client.getObject( any(GetObjectRequest.class) )).thenReturn(mockS3Obj); + Mockito.when(amazonS3Client.getObject(any(GetObjectRequest.class))).thenReturn(mockS3Obj); - InputStream actual = underTest.getCpcPlusValidationFile(); + byte[] actualBytes; + try (InputStream actual = underTest.getCpcPlusValidationFile()) { + assertThat(actual).isNotNull(); + actualBytes = toByteArray(actual); + } - assertThat(actual).isEqualTo(expected); + assertThat(actualBytes).isEqualTo(expectedBytes); + verify(mockS3Obj, times(1)).close(); } @Test - void test_getApmValidationFile() { - S3ObjectInputStream expected = new S3ObjectInputStream(null, null); + void test_getApmValidationFile() throws IOException { + byte[] expectedBytes = "APM".getBytes(StandardCharsets.UTF_8); + S3ObjectInputStream expectedStream = new S3ObjectInputStream(new ByteArrayInputStream(expectedBytes), null); S3Object mockS3Obj = mock(S3Object.class); - Mockito.when(mockS3Obj.getObjectContent()).thenReturn(expected); + Mockito.when(mockS3Obj.getObjectContent()).thenReturn(expectedStream); Mockito.when(environment.getProperty(Constants.BUCKET_NAME_ENV_VARIABLE)).thenReturn("Mock_Bucket"); - Mockito.when(amazonS3Client.getObject( any(GetObjectRequest.class) )).thenReturn(mockS3Obj); + Mockito.when(amazonS3Client.getObject(any(GetObjectRequest.class))).thenReturn(mockS3Obj); - InputStream actual = underTest.getApmValidationFile(Constants.CPC_PLUS_APM_FILE_NAME_KEY); + byte[] actualBytes; + try (InputStream actual = underTest.getApmValidationFile(Constants.CPC_PLUS_APM_FILE_NAME_KEY)) { + assertThat(actual).isNotNull(); + actualBytes = toByteArray(actual); + } - assertThat(actual).isEqualTo(expected); + assertThat(actualBytes).isEqualTo(expectedBytes); + verify(mockS3Obj, times(1)).close(); + } + + private byte[] toByteArray(InputStream inputStream) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] data = new byte[1024]; + int bytesRead; + while ((bytesRead = inputStream.read(data)) != -1) { + buffer.write(data, 0, bytesRead); + } + return buffer.toByteArray(); } } From f00b11214e26acb71d6aff8e987f79f1ddd39fe8 Mon Sep 17 00:00:00 2001 From: John Manack Date: Mon, 16 Feb 2026 10:51:02 -0500 Subject: [PATCH 08/19] feat: QPPA-11251 adds enhanced error handling for s3 stream closure --- .../services/internal/StorageServiceImpl.java | 14 +++++++- .../internal/StorageServiceImplTest.java | 35 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/rest-api/src/main/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImpl.java b/rest-api/src/main/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImpl.java index ee28d988e7..746c9d13bf 100644 --- a/rest-api/src/main/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImpl.java +++ b/rest-api/src/main/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImpl.java @@ -192,10 +192,22 @@ private static final class ManagedS3InputStream extends FilterInputStream { @Override public void close() throws IOException { + IOException firstException = null; try { super.close(); + } catch (IOException e) { + firstException = e; + throw e; } finally { - s3Object.close(); + try { + s3Object.close(); + } catch (IOException e) { + if (firstException != null) { + firstException.addSuppressed(e); + } else { + throw e; + } + } } } } diff --git a/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImplTest.java b/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImplTest.java index d4e8e7341e..367a962085 100644 --- a/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImplTest.java +++ b/rest-api/src/test/java/gov/cms/qpp/conversion/api/services/internal/StorageServiceImplTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -13,6 +14,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -221,6 +223,28 @@ void test_getCpcPlusValidationFile() throws IOException { verify(mockS3Obj, times(1)).close(); } + @Test + void managedInputStream_closeAddsSuppressedWhenSuperAndS3CloseFail() throws IOException { + byte[] bytes = "Mock Contents".getBytes(StandardCharsets.UTF_8); + S3ObjectInputStream expectedStream = new S3ObjectInputStream(new CloseFailingInputStream(bytes), null); + S3Object mockS3Obj = mock(S3Object.class); + Mockito.when(mockS3Obj.getObjectContent()).thenReturn(expectedStream); + doThrow(new IOException("S3 close failure")).when(mockS3Obj).close(); + + Mockito.when(environment.getProperty(Constants.CPC_PLUS_BUCKET_NAME_VARIABLE)).thenReturn("Mock_Bucket"); + Mockito.when(environment.getProperty(Constants.CPC_PLUS_FILENAME_VARIABLE)).thenReturn("Mock_Filename"); + Mockito.when(amazonS3Client.getObject(any(GetObjectRequest.class))).thenReturn(mockS3Obj); + + InputStream managedStream = underTest.getCpcPlusValidationFile(); + assertThat(managedStream).isNotNull(); + IOException thrown = assertThrows(IOException.class, managedStream::close); + + assertThat(thrown.getMessage()).contains("Input stream close failure"); + assertThat(thrown.getSuppressed()).hasLength(1); + assertThat(thrown.getSuppressed()[0].getMessage()).contains("S3 close failure"); + verify(mockS3Obj, times(1)).close(); + } + @Test void test_getApmValidationFile() throws IOException { byte[] expectedBytes = "APM".getBytes(StandardCharsets.UTF_8); @@ -250,4 +274,15 @@ private byte[] toByteArray(InputStream inputStream) throws IOException { } return buffer.toByteArray(); } + + private static final class CloseFailingInputStream extends FilterInputStream { + CloseFailingInputStream(byte[] data) { + super(new ByteArrayInputStream(data)); + } + + @Override + public void close() throws IOException { + throw new IOException("Input stream close failure"); + } + } } From 1833abe473befae449f3307ff73ebcb94033e282 Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Mon, 16 Feb 2026 12:50:05 -0600 Subject: [PATCH 09/19] fix: QPPA-0000 fix release bug --- .github/workflows/draft-new-release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/draft-new-release.yml b/.github/workflows/draft-new-release.yml index d13197fac4..5ed3022b97 100644 --- a/.github/workflows/draft-new-release.yml +++ b/.github/workflows/draft-new-release.yml @@ -71,9 +71,10 @@ jobs: git push origin release/v${{ github.event.inputs.version }} - name: Create pull request to master branch - uses: thomaseizinger/create-pull-request@1.4.0 + uses: thomaseizinger/create-pull-request@1.4.0 + env: + GITHUB_TOKEN: ${{ secrets.ACTIONS_NICHOLAS_PAT }} # MUST use a PAT here in order to trigger the next workflow: CodeBuild Trigger; see https://docs.github.com/en/actions/using-workflows/triggering-a-workflow with: - github_token: ${{ secrets.ACTIONS_NICHOLAS_PAT }} # MUST use a PAT here in order to trigger the next workflow: CodeBuild Trigger; see https://docs.github.com/en/actions/using-workflows/triggering-a-workflow head: release/v${{ github.event.inputs.version }} base: master draft: true From cc239c8fe5de4d1b1667849a75f4924a1430fbd2 Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Mon, 16 Feb 2026 12:53:25 -0600 Subject: [PATCH 10/19] fix: QPPA-0000 fix release bug --- .github/workflows/draft-new-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/draft-new-release.yml b/.github/workflows/draft-new-release.yml index 5ed3022b97..3b1bb49607 100644 --- a/.github/workflows/draft-new-release.yml +++ b/.github/workflows/draft-new-release.yml @@ -61,7 +61,7 @@ jobs: id: make-commit run: echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - - name: Commit changelog and manifest files + - name: Commit pom files run: | git add '**/pom.xml' pom.xml git commit --message "Prepare release v${{ github.event.inputs.version }}" From 2a5e269e1f6a041bf4f2ede709dd185702075396 Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Mon, 16 Feb 2026 12:59:31 -0600 Subject: [PATCH 11/19] fix: QPPA-0000 fix release bug --- .github/workflows/draft-new-release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/draft-new-release.yml b/.github/workflows/draft-new-release.yml index 3b1bb49607..6a65c3e4df 100644 --- a/.github/workflows/draft-new-release.yml +++ b/.github/workflows/draft-new-release.yml @@ -48,9 +48,9 @@ jobs: # Update all pom.xml files using Maven versions plugin echo "Updating version in all pom.xml files to ${{ github.event.inputs.version }}-RELEASE..." mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false - echo "Version update complete." - - # In order to make a commit, we need to initialize a user. + mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false -f acceptance-tests/pom.xml + mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false -f generate-race-cpcplus/pom.xml + mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false -f qrda3-update-measures/pom.xml # You may choose to write something less generic here if you want, it doesn't matter functionality-wise. - name: Initialize mandatory git config run: | From 3522e7e2a9f90973447da34eafba39b885ef5905 Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Mon, 16 Feb 2026 13:13:31 -0600 Subject: [PATCH 12/19] fix: QPPA-0000 fix release bug --- buildspec/build_deploy.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/buildspec/build_deploy.yaml b/buildspec/build_deploy.yaml index 9f96caa64b..a9fb9b5359 100644 --- a/buildspec/build_deploy.yaml +++ b/buildspec/build_deploy.yaml @@ -60,7 +60,8 @@ phases: fi - echo "${CLUSTER_NAME}" - SERVICE_NAME="${ENV}-conversion-tool" - - BRANCH=$(echo "${CODEBUILD_SOURCE_VERSION}") + # Extract the branch name from the CodeBuild source version. The source version is in the format "branch/branch-name" for GitHub source, so we can use 'sed' to remove the "branch/" prefix and get just the branch name. + - BRANCH=$(echo "${CODEBUILD_SOURCE_VERSION}" | sed 's/.*\///') - echo ${CODEBUILD_RESOLVED_SOURCE_VERSION} - echo "${BRANCH}" - COMMIT_SHORT_SHA=$(echo "${CODEBUILD_RESOLVED_SOURCE_VERSION}" | cut -c1-7) From 651cdbd069723b2929dcd9bcc81bdab16d0c8c10 Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Mon, 16 Feb 2026 13:14:55 -0600 Subject: [PATCH 13/19] fix: QPPA-0000 fix release bug --- .github/workflows/draft-new-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/draft-new-release.yml b/.github/workflows/draft-new-release.yml index 6a65c3e4df..2837ac5cdb 100644 --- a/.github/workflows/draft-new-release.yml +++ b/.github/workflows/draft-new-release.yml @@ -51,6 +51,9 @@ jobs: mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false -f acceptance-tests/pom.xml mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false -f generate-race-cpcplus/pom.xml mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false -f qrda3-update-measures/pom.xml + echo "Version update complete." + + # In order to make a commit, we need to initialize a user. # You may choose to write something less generic here if you want, it doesn't matter functionality-wise. - name: Initialize mandatory git config run: | From 48929832f144e15a6e23906bfd77078cb07bdb2e Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Mon, 16 Feb 2026 13:53:24 -0600 Subject: [PATCH 14/19] fix: QPPA-0000 fix release bug --- .github/workflows/draft-new-release.yml | 2 -- pom.xml | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/draft-new-release.yml b/.github/workflows/draft-new-release.yml index 2837ac5cdb..7054ceff18 100644 --- a/.github/workflows/draft-new-release.yml +++ b/.github/workflows/draft-new-release.yml @@ -49,8 +49,6 @@ jobs: echo "Updating version in all pom.xml files to ${{ github.event.inputs.version }}-RELEASE..." mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false -f acceptance-tests/pom.xml - mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false -f generate-race-cpcplus/pom.xml - mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false -f qrda3-update-measures/pom.xml echo "Version update complete." # In order to make a commit, we need to initialize a user. diff --git a/pom.xml b/pom.xml index b7f2a8651e..3c272d5bfb 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,8 @@ commandline rest-api test-coverage + generate-race-cpcplus + qrda3-update-measures From da79b00ec66e2c54b0a5b85cf211f2e0f33e9f3a Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Mon, 16 Feb 2026 14:00:54 -0600 Subject: [PATCH 15/19] fix: QPPA-0000 fix release bug --- pom.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pom.xml b/pom.xml index 3c272d5bfb..b7f2a8651e 100644 --- a/pom.xml +++ b/pom.xml @@ -36,8 +36,6 @@ commandline rest-api test-coverage - generate-race-cpcplus - qrda3-update-measures From 62e1d4dfe33d1075d81655d5fa697e4524847bb2 Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Mon, 16 Feb 2026 14:26:57 -0600 Subject: [PATCH 16/19] fix: QPPA-0000 fix release bug --- pom.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pom.xml b/pom.xml index b7f2a8651e..3c272d5bfb 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,8 @@ commandline rest-api test-coverage + generate-race-cpcplus + qrda3-update-measures From 620274eee7bb19209c84453cd49bf2c6b1df7df8 Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Mon, 16 Feb 2026 14:46:13 -0600 Subject: [PATCH 17/19] fix: QPPA-0000 fix release bug --- .github/workflows/draft-new-release.yml | 9 +++++++-- generate-race-cpcplus/pom.xml | 17 +++++++++++------ pom.xml | 2 -- qrda3-update-measures/pom.xml | 18 +++++++++++------- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.github/workflows/draft-new-release.yml b/.github/workflows/draft-new-release.yml index 7054ceff18..d780b7b1aa 100644 --- a/.github/workflows/draft-new-release.yml +++ b/.github/workflows/draft-new-release.yml @@ -45,10 +45,15 @@ jobs: - name: Bump version in pom.xml files run: | - # Update all pom.xml files using Maven versions plugin - echo "Updating version in all pom.xml files to ${{ github.event.inputs.version }}-RELEASE..." + # Update parent and child module pom.xml files + echo "Updating version in parent and module pom.xml files to ${{ github.event.inputs.version }}-RELEASE..." mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false + + # Update standalone module pom.xml files + echo "Updating standalone module versions..." mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false -f acceptance-tests/pom.xml + mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false -f generate-race-cpcplus/pom.xml + mvn versions:set -DnewVersion=${{ github.event.inputs.version }}-RELEASE -DgenerateBackupPoms=false -f qrda3-update-measures/pom.xml echo "Version update complete." # In order to make a commit, we need to initialize a user. diff --git a/generate-race-cpcplus/pom.xml b/generate-race-cpcplus/pom.xml index ae02242062..eba860f165 100644 --- a/generate-race-cpcplus/pom.xml +++ b/generate-race-cpcplus/pom.xml @@ -2,15 +2,20 @@ - - qpp-conversion-tool-parent - gov.cms.qpp.conversion - 2026.01.28.01-RELEASE - ../ - 4.0.0 + gov.cms.qpp.conversion generateRaceCpcPlus + 2026.01.28.01-RELEASE + generate-race-cpcplus + jar + + + 17 + UTF-8 + ${java.version} + ${java.version} + diff --git a/pom.xml b/pom.xml index 3c272d5bfb..b7f2a8651e 100644 --- a/pom.xml +++ b/pom.xml @@ -36,8 +36,6 @@ commandline rest-api test-coverage - generate-race-cpcplus - qrda3-update-measures diff --git a/qrda3-update-measures/pom.xml b/qrda3-update-measures/pom.xml index 1798b3887a..898357e4b6 100644 --- a/qrda3-update-measures/pom.xml +++ b/qrda3-update-measures/pom.xml @@ -1,15 +1,19 @@ - - qpp-conversion-tool-parent - gov.cms.qpp.conversion - 2026.01.28.01-RELEASE - ../ - + 4.0.0 + gov.cms.qpp.conversion qpp-update-measures + 026.01.28.01-RELEASE + qrda3-update-measures jar - 4.0.0 + + + 17 + UTF-8 + ${java.version} + ${java.version} + From cc06d11bb3901960449267177e1bb353a4baad5d Mon Sep 17 00:00:00 2001 From: ckawell-sb Date: Mon, 16 Feb 2026 15:12:56 -0600 Subject: [PATCH 18/19] fix: QPPA-0000 fix release bug --- .github/workflows/draft-new-release.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/draft-new-release.yml b/.github/workflows/draft-new-release.yml index d780b7b1aa..de50a90440 100644 --- a/.github/workflows/draft-new-release.yml +++ b/.github/workflows/draft-new-release.yml @@ -12,12 +12,12 @@ jobs: name: "Draft a new release" runs-on: ubuntu-latest if: github.ref_name == 'develop' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} outputs: commitSha: ${{ steps.make-commit.outputs.commit }} steps: - uses: actions/checkout@v4 + with: + token: ${{ secrets.ACTIONS_NICHOLAS_PAT }} - name: Create release branch run: | @@ -33,7 +33,7 @@ jobs: prerelease: false publish: false env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.ACTIONS_NICHOLAS_PAT }} - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -63,15 +63,15 @@ jobs: git config user.name "GitHub actions" git config user.email noreply@github.com - - name: Fetch commit hash - id: make-commit - run: echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - - name: Commit pom files run: | git add '**/pom.xml' pom.xml git commit --message "Prepare release v${{ github.event.inputs.version }}" + - name: Fetch commit hash + id: make-commit + run: echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + - name: Push new branch run: | git push origin release/v${{ github.event.inputs.version }} From 4b9e88c484bdf3c9aade3cc8a5e2f146a30f19fc Mon Sep 17 00:00:00 2001 From: GitHub actions Date: Tue, 17 Feb 2026 15:56:30 +0000 Subject: [PATCH 19/19] Prepare release v2026.02.17.01 --- acceptance-tests/pom.xml | 2 +- commandline/pom.xml | 2 +- commons/pom.xml | 2 +- converter/pom.xml | 4 ++-- generate-race-cpcplus/pom.xml | 2 +- generate/pom.xml | 2 +- pom.xml | 2 +- qrda3-update-measures/pom.xml | 2 +- rest-api/pom.xml | 2 +- test-commons/pom.xml | 2 +- test-coverage/pom.xml | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/acceptance-tests/pom.xml b/acceptance-tests/pom.xml index 5bffbf7e08..681cb59fd5 100644 --- a/acceptance-tests/pom.xml +++ b/acceptance-tests/pom.xml @@ -3,7 +3,7 @@ 4.0.0 acceptance-tests gov.cms.qpp.conversion - 2026.01.28.01-RELEASE + 2026.02.17.01-RELEASE conversion-tests jar diff --git a/commandline/pom.xml b/commandline/pom.xml index 46ac280a8c..8da3eba944 100644 --- a/commandline/pom.xml +++ b/commandline/pom.xml @@ -6,7 +6,7 @@ gov.cms.qpp.conversion qpp-conversion-tool-parent - 2026.01.28.01-RELEASE + 2026.02.17.01-RELEASE ../pom.xml diff --git a/commons/pom.xml b/commons/pom.xml index 0edb846b98..11fdf3d7d8 100644 --- a/commons/pom.xml +++ b/commons/pom.xml @@ -6,7 +6,7 @@ gov.cms.qpp.conversion qpp-conversion-tool-parent - 2026.01.28.01-RELEASE + 2026.02.17.01-RELEASE ../pom.xml diff --git a/converter/pom.xml b/converter/pom.xml index d0aec7a52f..5854a098dc 100644 --- a/converter/pom.xml +++ b/converter/pom.xml @@ -6,7 +6,7 @@ gov.cms.qpp.conversion qpp-conversion-tool-parent - 2026.01.28.01-RELEASE + 2026.02.17.01-RELEASE ../pom.xml @@ -185,7 +185,7 @@ gov.cms.qpp.conversion commons - 2026.01.28.01-RELEASE + 2026.02.17.01-RELEASE compile diff --git a/generate-race-cpcplus/pom.xml b/generate-race-cpcplus/pom.xml index eba860f165..7f2a135e26 100644 --- a/generate-race-cpcplus/pom.xml +++ b/generate-race-cpcplus/pom.xml @@ -6,7 +6,7 @@ gov.cms.qpp.conversion generateRaceCpcPlus - 2026.01.28.01-RELEASE + 2026.02.17.01-RELEASE generate-race-cpcplus jar diff --git a/generate/pom.xml b/generate/pom.xml index 52e563ed5c..ebca4f0224 100644 --- a/generate/pom.xml +++ b/generate/pom.xml @@ -5,7 +5,7 @@ qpp-conversion-tool-parent gov.cms.qpp.conversion - 2026.01.28.01-RELEASE + 2026.02.17.01-RELEASE ../pom.xml 4.0.0 diff --git a/pom.xml b/pom.xml index b7f2a8651e..12ce54b7d3 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ gov.cms.qpp.conversion qpp-conversion-tool-parent pom - 2026.01.28.01-RELEASE + 2026.02.17.01-RELEASE QPP Conversion Tool diff --git a/qrda3-update-measures/pom.xml b/qrda3-update-measures/pom.xml index 898357e4b6..374c4795f4 100644 --- a/qrda3-update-measures/pom.xml +++ b/qrda3-update-measures/pom.xml @@ -4,7 +4,7 @@ gov.cms.qpp.conversion qpp-update-measures - 026.01.28.01-RELEASE + 2026.02.17.01-RELEASE qrda3-update-measures jar diff --git a/rest-api/pom.xml b/rest-api/pom.xml index c4449322ba..ee1a285862 100644 --- a/rest-api/pom.xml +++ b/rest-api/pom.xml @@ -19,7 +19,7 @@ gov.cms.qpp.conversion qpp-conversion-tool-parent - 2026.01.28.01-RELEASE + 2026.02.17.01-RELEASE ../pom.xml diff --git a/test-commons/pom.xml b/test-commons/pom.xml index a64aea6dbf..58463b598c 100644 --- a/test-commons/pom.xml +++ b/test-commons/pom.xml @@ -6,7 +6,7 @@ gov.cms.qpp.conversion qpp-conversion-tool-parent - 2026.01.28.01-RELEASE + 2026.02.17.01-RELEASE ../pom.xml diff --git a/test-coverage/pom.xml b/test-coverage/pom.xml index 3597f0b0c1..dcaee6ac60 100644 --- a/test-coverage/pom.xml +++ b/test-coverage/pom.xml @@ -6,7 +6,7 @@ gov.cms.qpp.conversion qpp-conversion-tool-parent - 2026.01.28.01-RELEASE + 2026.02.17.01-RELEASE ../pom.xml