diff --git a/gp2gp-translator/build.gradle b/gp2gp-translator/build.gradle index 6f6e8d5ee..96bf3aabd 100644 --- a/gp2gp-translator/build.gradle +++ b/gp2gp-translator/build.gradle @@ -50,6 +50,7 @@ dependencies { testImplementation 'org.skyscreamer:jsonassert:1.5.3' testImplementation 'org.awaitility:awaitility:4.2.2' testImplementation 'io.findify:s3mock_2.13:0.2.6' + testImplementation 'org.testcontainers:testcontainers-azure:2.0.5' pitest 'com.arcmutate:base:1.3.2' pitest 'com.arcmutate:pitest-git-plugin:2.0.0' diff --git a/gp2gp-translator/src/integrationTest/java/uk/nhs/adaptors/pss/translator/storage/AzureStorageServiceTest.java b/gp2gp-translator/src/integrationTest/java/uk/nhs/adaptors/pss/translator/storage/AzureStorageServiceTest.java new file mode 100644 index 000000000..31305c91d --- /dev/null +++ b/gp2gp-translator/src/integrationTest/java/uk/nhs/adaptors/pss/translator/storage/AzureStorageServiceTest.java @@ -0,0 +1,102 @@ +package uk.nhs.adaptors.pss.translator.storage; + +import com.azure.core.util.BinaryData; +import com.azure.storage.blob.BlobServiceClient; +import com.azure.storage.blob.BlobServiceClientBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.azure.AzuriteContainer; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class AzureStorageServiceTest { + + private static final String CONTAINER_NAME = "azurecontainer"; + private static final String FILE_NAME = "testfile.txt"; + + private AzureStorageService azureStorageService; + private AzuriteContainer azuriteContainer; + private BlobServiceClient blobServiceClient; + + @BeforeEach + void setUp() { + azuriteContainer = new AzuriteContainer("mcr.microsoft.com/azure-storage/azurite:3.33.0"); + azuriteContainer.start(); + + blobServiceClient = new BlobServiceClientBuilder() + .connectionString(azuriteContainer.getConnectionString()) + .buildClient(); + blobServiceClient.createBlobContainer(CONTAINER_NAME); + + StorageServiceConfiguration config = new StorageServiceConfiguration(); + config.setContainerName(CONTAINER_NAME); + + azureStorageService = new AzureStorageService(blobServiceClient, config); + } + + @AfterEach + void tearDown() { + azuriteContainer.stop(); + } + + @Test + void uploadToStorageTest() { + String uploadContent = "uploadcontent"; + + azureStorageService.uploadFile(FILE_NAME, uploadContent.getBytes(StandardCharsets.UTF_8)); + + byte[] downloaded = blobServiceClient + .getBlobContainerClient(CONTAINER_NAME) + .getBlobClient(FILE_NAME).downloadContent().toBytes(); + + Assertions.assertEquals(uploadContent, new String(downloaded, StandardCharsets.UTF_8)); + } + + @Test + void downloadFromStorageTest() { + + String fileContent = "dummy-content"; + + blobServiceClient + .getBlobContainerClient(CONTAINER_NAME) + .getBlobClient(FILE_NAME).upload(BinaryData.fromString(fileContent)); + + byte[] response = azureStorageService.downloadFile(FILE_NAME); + String downloadedContent = new String(response, StandardCharsets.UTF_8); + + assertNotNull(response); + Assertions.assertEquals(fileContent, downloadedContent); + } + + @Test + void deleteFileTest() { + + String fileContent = "dummy-content"; + blobServiceClient + .getBlobContainerClient(CONTAINER_NAME) + .getBlobClient(FILE_NAME).upload(BinaryData.fromString(fileContent)); + + azureStorageService.deleteFile(FILE_NAME); + + Exception exception = Assertions.assertThrows(Exception.class, () -> azureStorageService.downloadFile(FILE_NAME)); + + Assertions.assertEquals("Status code 404, BlobNotFound", exception.getMessage()); + } + + @Test + void getFileLocationTest() { + String fileContent = "dummy-content"; + blobServiceClient + .getBlobContainerClient(CONTAINER_NAME) + .getBlobClient(FILE_NAME).upload(BinaryData.fromString(fileContent)); + + String response = azureStorageService.getFileLocation(FILE_NAME); + + assertNotNull(response); + Assertions.assertTrue(response.contains(FILE_NAME)); + } +} \ No newline at end of file diff --git a/gp2gp-translator/src/main/java/uk/nhs/adaptors/pss/translator/storage/AzureStorageService.java b/gp2gp-translator/src/main/java/uk/nhs/adaptors/pss/translator/storage/AzureStorageService.java index 81e19f2fb..17d4e4615 100644 --- a/gp2gp-translator/src/main/java/uk/nhs/adaptors/pss/translator/storage/AzureStorageService.java +++ b/gp2gp-translator/src/main/java/uk/nhs/adaptors/pss/translator/storage/AzureStorageService.java @@ -4,32 +4,21 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; -import java.util.Locale; import com.azure.storage.blob.BlobContainerClient; import com.azure.storage.blob.BlobServiceClient; -import com.azure.storage.blob.BlobServiceClientBuilder; import com.azure.storage.blob.specialized.BlockBlobClient; -import com.azure.storage.common.StorageSharedKeyCredential; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +@SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "BlobServiceClient is immutable and thread-safe.") public class AzureStorageService implements StorageService { - // Consistent objects private final BlobServiceClient blobServiceClient; - private String containerName; + private final String containerName; - public AzureStorageService(StorageServiceConfiguration configuration) { - if (!configuration.getAccountReference().isEmpty()) { - - StorageSharedKeyCredential credentials = createAzureCredentials( - configuration.getAccountReference(), configuration.getAccountSecret()); - - String azureEndpoint = createAzureStorageEndpoint(configuration.getAccountReference()); - blobServiceClient = createBlobServiceClient(azureEndpoint, credentials); - containerName = configuration.getContainerName(); - } else { - blobServiceClient = null; - } + public AzureStorageService(BlobServiceClient client, StorageServiceConfiguration config) { + blobServiceClient = client; + containerName = config.getContainerName(); } public void uploadFile(String filename, byte[] fileAsString) throws StorageException { @@ -56,22 +45,6 @@ public String getFileLocation(String filename) { return blobClient.getBlobUrl(); } - private StorageSharedKeyCredential createAzureCredentials(String accountName, String accountKey) { - return new StorageSharedKeyCredential(accountName, accountKey); - } - - private String createAzureStorageEndpoint(String containerName) { - return String.format(Locale.ROOT, "https://%s.blob.core.windows.net", containerName); - } - - private BlobServiceClient createBlobServiceClient(String endpoint, StorageSharedKeyCredential credentials) { - - return new BlobServiceClientBuilder() - .endpoint(endpoint) - .credential(credentials) - .buildClient(); - } - private BlobContainerClient createBlobContainerClient() { return blobServiceClient.getBlobContainerClient(containerName); } diff --git a/gp2gp-translator/src/main/java/uk/nhs/adaptors/pss/translator/storage/StorageServiceConfig.java b/gp2gp-translator/src/main/java/uk/nhs/adaptors/pss/translator/storage/StorageServiceConfig.java new file mode 100644 index 000000000..63a14dc0d --- /dev/null +++ b/gp2gp-translator/src/main/java/uk/nhs/adaptors/pss/translator/storage/StorageServiceConfig.java @@ -0,0 +1,55 @@ +package uk.nhs.adaptors.pss.translator.storage; + +import com.azure.storage.blob.BlobServiceClient; +import com.azure.storage.blob.BlobServiceClientBuilder; +import com.azure.storage.common.StorageSharedKeyCredential; +import jakarta.annotation.Nonnull; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.util.Locale; + +@Configuration +public class StorageServiceConfig { + + @Bean + @ConditionalOnProperty(name = "storage.type", havingValue = "Azure") + public @Nonnull StorageService azureStorageService(StorageServiceConfiguration configuration) { + StorageSharedKeyCredential credentials = new StorageSharedKeyCredential( + configuration.getAccountReference(), + configuration.getAccountSecret() + ); + + String endpoint = String.format( + Locale.ROOT, + "https://%s.blob.core.windows.net", + configuration.getAccountReference() + ); + + BlobServiceClient blobServiceClient = new BlobServiceClientBuilder() + .endpoint(endpoint) + .credential(credentials) + .buildClient(); + + return new AzureStorageService(blobServiceClient, configuration); + } + + @Bean + @ConditionalOnProperty(name = "storage.type", havingValue = "S3") + public @Nonnull StorageService awsStorageService(StorageServiceConfiguration configuration) { + return new AWSStorageService( + S3Client.builder().build(), + configuration, + S3Presigner.builder().build() + ); + } + + @Bean + @ConditionalOnProperty(name = "storage.type", havingValue = "LocalMock", matchIfMissing = true) + public @Nonnull StorageService localStorageService() { + return new LocalStorageService(); + } +} diff --git a/gp2gp-translator/src/main/java/uk/nhs/adaptors/pss/translator/storage/StorageServiceFactory.java b/gp2gp-translator/src/main/java/uk/nhs/adaptors/pss/translator/storage/StorageServiceFactory.java deleted file mode 100644 index e73508e1d..000000000 --- a/gp2gp-translator/src/main/java/uk/nhs/adaptors/pss/translator/storage/StorageServiceFactory.java +++ /dev/null @@ -1,37 +0,0 @@ -package uk.nhs.adaptors.pss.translator.storage; - -import lombok.Setter; -import org.springframework.beans.factory.FactoryBean; -import org.springframework.beans.factory.annotation.Autowired; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; - -@Setter -public class StorageServiceFactory implements FactoryBean { - - @Autowired - private StorageServiceConfiguration configuration; - - public StorageService getObject() { - - // we cannot create a private instance of storageService without triggering - // an EI_EXPOSE_REP via Spotbug tests - StorageService storageService = null; - switch (StorageServiceOptionsEnum.enumOf(configuration.getType())) { - case S3: - storageService = new AWSStorageService(S3Client.builder().build(), configuration, S3Presigner.builder().build()); - break; - case AZURE: - storageService = new AzureStorageService(configuration); - break; - default: - storageService = new LocalStorageService(); - } - - return storageService; - } - - public Class getObjectType() { - return StorageService.class; - } -} \ No newline at end of file diff --git a/gp2gp-translator/src/main/java/uk/nhs/adaptors/pss/translator/storage/StorageServiceFactoryConfig.java b/gp2gp-translator/src/main/java/uk/nhs/adaptors/pss/translator/storage/StorageServiceFactoryConfig.java deleted file mode 100644 index 82c789684..000000000 --- a/gp2gp-translator/src/main/java/uk/nhs/adaptors/pss/translator/storage/StorageServiceFactoryConfig.java +++ /dev/null @@ -1,18 +0,0 @@ -package uk.nhs.adaptors.pss.translator.storage; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class StorageServiceFactoryConfig { - - @Bean(name = "storage-service") - public StorageServiceFactory storageServiceFactory() { - return new StorageServiceFactory(); - } - - @Bean - public StorageService storageService() { - return storageServiceFactory().getObject(); - } -} \ No newline at end of file diff --git a/gp2gp-translator/src/test/java/uk/nhs/adaptors/pss/translator/storage/AzureStorageServiceTest.java b/gp2gp-translator/src/test/java/uk/nhs/adaptors/pss/translator/storage/AzureStorageServiceTest.java new file mode 100644 index 000000000..2d46d31f1 --- /dev/null +++ b/gp2gp-translator/src/test/java/uk/nhs/adaptors/pss/translator/storage/AzureStorageServiceTest.java @@ -0,0 +1,105 @@ +package uk.nhs.adaptors.pss.translator.storage; + +import com.azure.storage.blob.BlobContainerClient; +import com.azure.storage.blob.BlobServiceClient; +import com.azure.storage.blob.models.BlobProperties; +import com.azure.storage.blob.specialized.BlockBlobClient; +import com.azure.storage.blob.BlobClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class AzureStorageServiceTest { + + private static final String CONTAINER_NAME = "container"; + private static final String FILE_NAME = "testfile.txt"; + private static final byte[] FILE_CONTENT = "mock-content".getBytes(StandardCharsets.UTF_8); + + @Mock + private BlobServiceClient blobServiceClient; + @Mock + private StorageServiceConfiguration configuration; + @Mock + private BlobContainerClient blobContainerClient; + @Mock + private BlobClient blobClient; + @Mock + private BlockBlobClient blockBlobClient; + @Mock + private BlobProperties blobProperties; + + private AzureStorageService azureStorageService; + + @BeforeEach + void setUp() { + when(configuration.getContainerName()).thenReturn(CONTAINER_NAME); + azureStorageService = new AzureStorageService(blobServiceClient, configuration); + } + + private void mockBlobClientChain() { + when(blobServiceClient.getBlobContainerClient(CONTAINER_NAME)).thenReturn(blobContainerClient); + when(blobContainerClient.getBlobClient(FILE_NAME)).thenReturn(blobClient); + when(blobClient.getBlockBlobClient()).thenReturn(blockBlobClient); + } + + @Test + void When_UploadFile_Expect_SuccessfullyUploadsToAzure() throws StorageException { + mockBlobClientChain(); + + azureStorageService.uploadFile(FILE_NAME, FILE_CONTENT); + + verify(blockBlobClient).upload(any(InputStream.class), eq((long) FILE_CONTENT.length)); + } + + @Test + void When_DownloadFile_Expect_SuccessfullyDownloadsFromAzure() throws StorageException { + mockBlobClientChain(); + when(blockBlobClient.getProperties()).thenReturn(blobProperties); + when(blobProperties.getBlobSize()).thenReturn((long) FILE_CONTENT.length); + + doAnswer(invocation -> { + ByteArrayOutputStream outputStream = invocation.getArgument(0); + outputStream.write(FILE_CONTENT); + return null; + }).when(blockBlobClient).downloadStream(any(ByteArrayOutputStream.class)); + + byte[] result = azureStorageService.downloadFile(FILE_NAME); + + assertArrayEquals(FILE_CONTENT, result); + } + + @Test + void When_DeleteFile_Expect_SuccessfullyDeletesFromAzure() { + mockBlobClientChain(); + + azureStorageService.deleteFile(FILE_NAME); + + verify(blockBlobClient).delete(); + } + + @Test + void When_GetFileLocation_Expect_ReturnsCorrectUrl() { + mockBlobClientChain(); + String expectedUrl = "https://azuremock.blob.core.windows.net/test-container/testfile.txt"; + when(blockBlobClient.getBlobUrl()).thenReturn(expectedUrl); + + String result = azureStorageService.getFileLocation(FILE_NAME); + + assertEquals(expectedUrl, result); + } +} \ No newline at end of file diff --git a/gp2gp-translator/src/test/java/uk/nhs/adaptors/pss/translator/storage/StorageServiceConfigTest.java b/gp2gp-translator/src/test/java/uk/nhs/adaptors/pss/translator/storage/StorageServiceConfigTest.java new file mode 100644 index 000000000..b6f233539 --- /dev/null +++ b/gp2gp-translator/src/test/java/uk/nhs/adaptors/pss/translator/storage/StorageServiceConfigTest.java @@ -0,0 +1,78 @@ +package uk.nhs.adaptors.pss.translator.storage; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; + +public class StorageServiceConfigTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(StorageServiceConfig.class); + + @Test + void When_TypeIsAzure_Expect_AzureStorageServiceIsCreated() { + // Arrange & Act + contextRunner.withPropertyValues("storage.type=Azure") + .withBean(StorageServiceConfiguration.class, () -> { + StorageServiceConfiguration config = new StorageServiceConfiguration(); + config.setAccountReference("account"); + config.setAccountSecret( + Base64.getEncoder().encodeToString("secret".getBytes(StandardCharsets.UTF_8)) + ); + config.setContainerName("container"); + return config; + }) + // Assert + .run(context -> { + assertThat(context).hasSingleBean(StorageService.class); + assertThat(context).hasBean("azureStorageService"); + assertThat(context.getBean(StorageService.class)).isInstanceOf(AzureStorageService.class); + }); + } + + @Test + void When_TypeIsS3_Expect_AwsStorageServiceIsCreated() { + System.setProperty("aws.region", "eu-west-2"); + + contextRunner.withPropertyValues("storage.type=S3") + .withBean(StorageServiceConfiguration.class, StorageServiceConfiguration::new) + .run(context -> { + assertThat(context).hasSingleBean(StorageService.class); + assertThat(context).hasBean("awsStorageService"); + assertThat(context.getBean(StorageService.class)).isInstanceOf(AWSStorageService.class); + }); + + System.clearProperty("aws.region"); + } + + @Test + void When_TypeIsLocalMock_Expect_LocalStorageServiceIsCreated() { + contextRunner.withPropertyValues("storage.type=LocalMock") + .run(context -> { + assertThat(context).hasSingleBean(StorageService.class); + assertThat(context).hasBean("localStorageService"); + assertThat(context.getBean(StorageService.class)).isInstanceOf(LocalStorageService.class); + }); + } + + @Test + void When_TypeIsMissing_Expect_LocalStorageServiceIsCreated() { + contextRunner + .run(context -> { + assertThat(context).hasSingleBean(StorageService.class); + assertThat(context).hasBean("localStorageService"); + assertThat(context.getBean(StorageService.class)).isInstanceOf(LocalStorageService.class); + }); + } + + @Test + void When_TypeIsInvalid_Expect_StorageServiceIsNotCreated() { + contextRunner.withPropertyValues("storage.type=INVALID_TYPE") + .withBean(StorageServiceConfiguration.class, StorageServiceConfiguration::new) + .run(context -> assertThat(context).doesNotHaveBean(StorageService.class)); + } +} \ No newline at end of file