diff --git a/pom.xml b/pom.xml index cbe3846a..94aa2ef9 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ app.coronawarn cwa-parent - 1.8 + 1.8.1 cwa-quick-test-backend 1.0.0-SNAPSHOT @@ -139,6 +139,12 @@ eu.europa.ec.dgc dgc-lib + + net.lingala.zip4j + zip4j + + + @@ -158,6 +164,7 @@ org.codehaus.mojo license-maven-plugin + 2.0.0 diff --git a/src/main/java/app/coronawarn/quicktest/config/SecurityConfig.java b/src/main/java/app/coronawarn/quicktest/config/SecurityConfig.java index b8aeae92..41d1811c 100644 --- a/src/main/java/app/coronawarn/quicktest/config/SecurityConfig.java +++ b/src/main/java/app/coronawarn/quicktest/config/SecurityConfig.java @@ -52,6 +52,8 @@ public class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter { public static final String ROLE_POC_NAT_ADMIN = "ROLE_c19_quick_test_poc_nat_admin"; public static final String ROLE_TERMINATOR = "ROLE_c19_quick_test_terminator"; public static final String ROLE_ARCHIVE_OPERATOR = "ROLE_c19_quick_test_archive_operator"; + public static final String ROLE_ARCHIVE_ZIP_CREATOR = "ROLE_c19_quick_test_archive_operator_zip_creator"; + public static final String ROLE_ARCHIVE_ZIP_DOWNLOADER = "ROLE_c19_quick_test_archive_operator_zip_downloader"; private static final String API_ROUTE = "/api/**"; private static final String CONFIG_ROUTE = "/api/config/*"; diff --git a/src/main/java/app/coronawarn/quicktest/controller/ArchiveExportController.java b/src/main/java/app/coronawarn/quicktest/controller/ArchiveExportController.java index c7a2dcfc..63b94e5e 100644 --- a/src/main/java/app/coronawarn/quicktest/controller/ArchiveExportController.java +++ b/src/main/java/app/coronawarn/quicktest/controller/ArchiveExportController.java @@ -21,8 +21,12 @@ package app.coronawarn.quicktest.controller; import static app.coronawarn.quicktest.config.SecurityConfig.ROLE_ARCHIVE_OPERATOR; +import static app.coronawarn.quicktest.config.SecurityConfig.ROLE_ARCHIVE_ZIP_CREATOR; +import static app.coronawarn.quicktest.config.SecurityConfig.ROLE_ARCHIVE_ZIP_DOWNLOADER; +import app.coronawarn.quicktest.model.cancellation.ZipRequest; import app.coronawarn.quicktest.service.ArchiveService; +import app.coronawarn.quicktest.service.ArchiveZipService; import com.opencsv.exceptions.CsvDataTypeMismatchException; import com.opencsv.exceptions.CsvRequiredFieldEmptyException; import io.swagger.v3.oas.annotations.Operation; @@ -30,6 +34,14 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import java.io.IOException; +import java.net.URL; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; +import javax.validation.Valid; +import javax.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Profile; @@ -39,9 +51,13 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.annotation.Secured; import org.springframework.security.core.Authentication; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -51,10 +67,15 @@ @RequestMapping(value = "/api/archive") @RequiredArgsConstructor @Profile("archive_export") +@Validated public class ArchiveExportController { private final ArchiveService archiveService; + private final ArchiveZipService archiveZipService; + + private static final String zipFileNameRegex = "^\\w{0,20}.zip$"; + /** * Endpoint for downloading archived entities. * @@ -65,14 +86,14 @@ public class ArchiveExportController { description = "Creates a CSV-File with all archived data for whole Partner or just one POC ID.", parameters = { @Parameter( - in = ParameterIn.PATH, - name = "partnerId", - description = "Partner ID of the PArtner to download data of", - required = true), + in = ParameterIn.PATH, + name = "partnerId", + description = "Partner ID of the PArtner to download data of", + required = true), @Parameter( - in = ParameterIn.QUERY, - name = "pocId", - description = "Filter for entities with given pocId") + in = ParameterIn.QUERY, + name = "pocId", + description = "Filter for entities with given pocId") } ) @ApiResponses(value = { @@ -101,4 +122,80 @@ public ResponseEntity exportArchive(@PathVariable("partnerId") String pa throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create CSV."); } } + + /** + * Endpoint for creating a zip file with multiple CSV-files. + * + * @return Status Code. + */ + @Operation( + summary = "Create Archive ZIP-File", + description = "Creates a ZIP-File with all CSV-Files of provided partner ids." + + "ZIP File will be stored in OBS bucket" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "ZIP Created") + }) + @PostMapping(value = "/zip", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + @Secured({ROLE_ARCHIVE_ZIP_CREATOR}) + public ResponseEntity createZip(@Valid @RequestBody ZipRequest zipRequest) { + + try { + List partnerIds = zipRequest.getPartnerIds(); + partnerIds.add(zipRequest.getPartnerId()); + + archiveZipService.createZip( + zipRequest.getPartnerId() + ".zip", + zipRequest.getPartnerIds(), + zipRequest.getPassword()); + + } catch (ArchiveZipService.ArchiveZipServiceException e) { + if (e.getReason() == ArchiveZipService.ArchiveZipServiceException.Reason.CSV_FILE_NOT_FOUND) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "CSV File " + e.getMessage() + " not found"); + } else { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, + "Unexpected Error while creating ZIP File."); + } + } catch (IOException e) { + log.error("Failed to create ZIP (IOException): {}", e.getMessage()); + throw new RuntimeException(e); + } + + return ResponseEntity + .status(HttpStatus.CREATED) + .build(); + } + + /** + * Endpoint for receiving download link for Archive ZIP File. + * + * @return Download link of zip + */ + @Operation( + summary = "Request Download Link (ZIP)", + description = "Returns a presigned URL to download the generated ZIP File from Bucket." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successful"), + @ApiResponse(responseCode = "404", description = "No ZIP file found") + }) + @GetMapping(value = "/zip/download", produces = MediaType.APPLICATION_JSON_VALUE) + @Secured({ROLE_ARCHIVE_ZIP_DOWNLOADER}) + public ResponseEntity getZipLink( + @Valid @Pattern(regexp = zipFileNameRegex) @RequestParam("filename") String filename) { + + Date expiration = Date.from(Instant.now().plus(5, ChronoUnit.MINUTES)); + URL url; + try { + url = archiveZipService.getDownloadUrl(filename, expiration); + } catch (ArchiveZipService.ArchiveZipServiceException e) { + if (e.getReason() == ArchiveZipService.ArchiveZipServiceException.Reason.ZIP_FILE_NOT_FOUND) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "ZIP File doesn't exist in Bucket"); + } else { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); + } + } + + return ResponseEntity.ok(url); + } } diff --git a/src/main/java/app/coronawarn/quicktest/model/cancellation/ZipRequest.java b/src/main/java/app/coronawarn/quicktest/model/cancellation/ZipRequest.java new file mode 100644 index 00000000..a1f1b3a6 --- /dev/null +++ b/src/main/java/app/coronawarn/quicktest/model/cancellation/ZipRequest.java @@ -0,0 +1,44 @@ +/*- + * ---license-start + * Corona-Warn-App / cwa-quick-test-backend + * --- + * Copyright (C) 2021 - 2023 T-Systems International GmbH and all other contributors + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---license-end + */ + +package app.coronawarn.quicktest.model.cancellation; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import javax.validation.constraints.Size; +import lombok.Data; +import org.hibernate.validator.constraints.Length; + +@Schema( + description = "The ZIP request model to create zip file for partners." +) +@Data +public class ZipRequest { + + @Length(min = 10, max = 100) + private String password; + + @Length(min = 1, max = 50) + private String partnerId; + + @Size(min = 1, max = 20) + private List<@Length(min = 1, max = 100) String> partnerIds; + +} diff --git a/src/main/java/app/coronawarn/quicktest/service/ArchiveZipService.java b/src/main/java/app/coronawarn/quicktest/service/ArchiveZipService.java new file mode 100644 index 00000000..337c2133 --- /dev/null +++ b/src/main/java/app/coronawarn/quicktest/service/ArchiveZipService.java @@ -0,0 +1,172 @@ +/*- + * ---license-start + * Corona-Warn-App / cwa-quick-test-backend + * --- + * Copyright (C) 2021 - 2023 T-Systems International GmbH and all other contributors + * --- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ---license-end + */ + +package app.coronawarn.quicktest.service; + +import app.coronawarn.quicktest.config.CsvUploadConfig; +import com.amazonaws.AmazonServiceException; +import com.amazonaws.HttpMethod; +import com.amazonaws.SdkClientException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.S3Object; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.Date; +import java.util.List; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.lingala.zip4j.io.outputstream.ZipOutputStream; +import net.lingala.zip4j.model.ZipParameters; +import net.lingala.zip4j.model.enums.AesKeyStrength; +import net.lingala.zip4j.model.enums.AesVersion; +import net.lingala.zip4j.model.enums.CompressionMethod; +import net.lingala.zip4j.model.enums.EncryptionMethod; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ArchiveZipService { + + private final CsvUploadConfig csvConfig; + + private final AmazonS3 s3Client; + + /** + * Create an encrypted ZIP file in OBS Bucket for given partnerIds. + * + * @param filename Filename of the ZIP file to be created in Bucket + * @param partnerIds List of the PartnerIds to be added to ZIP file. + * @param password password to encrypt ZIP file + */ + public void createZip(String filename, List partnerIds, String password) + throws ArchiveZipServiceException, IOException { + + + ZipParameters zipParameters = new ZipParameters(); + zipParameters.setEncryptFiles(true); + zipParameters.setEncryptionMethod(EncryptionMethod.AES); + zipParameters.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_256); + zipParameters.setAesVersion(AesVersion.TWO); + zipParameters.setCompressionMethod(CompressionMethod.DEFLATE); + + try ( + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + ZipOutputStream zipStream = new ZipOutputStream(byteArrayOutputStream, password.toCharArray())) { + for (String partnerId : partnerIds) { + String csvFileName = partnerId + ".csv"; + S3Object s3Object; + + try { + s3Object = s3Client.getObject(csvConfig.getBucketName(), csvFileName); + } catch (AmazonServiceException e) { + if (e.getStatusCode() == 404) { + log.error("Could not find {} in bucket.", csvFileName); + throw new ArchiveZipServiceException(csvFileName, + ArchiveZipServiceException.Reason.CSV_FILE_NOT_FOUND); + } else { + log.error("Unexpected error when downloading CSV {} from bucket. Status Code: {}", + csvFileName, e.getStatusCode()); + throw new ArchiveZipServiceException(csvFileName, + ArchiveZipServiceException.Reason.UNEXPECTED_ERROR); + } + } + + byte[] buff = new byte[4096]; + int readLen; + + zipParameters.setFileNameInZip(csvFileName); + zipStream.putNextEntry(zipParameters); + try (InputStream inputStream = s3Object.getObjectContent()) { + while ((readLen = inputStream.read(buff)) != -1) { + zipStream.write(buff, 0, readLen); + } + } + zipStream.closeEntry(); + } + zipStream.close(); + + byte[] zipBytes = byteArrayOutputStream.toByteArray(); + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE); + objectMetadata.setContentLength(zipBytes.length); + s3Client.putObject( + csvConfig.getBucketName(), + filename, + new ByteArrayInputStream(zipBytes), + objectMetadata); + } catch (ArchiveZipServiceException e) { + throw e; + } catch (Exception e) { + log.error("Unexpected error while creating ZIP file.", e); + throw new ArchiveZipServiceException(e.getMessage(), ArchiveZipServiceException.Reason.UNEXPECTED_ERROR); + } + } + + /** + * Create a presigned URL to download a file from OBS bucket. + * + * @param filename name of the file in bucket. + * @return URL to download file. + * @throws ArchiveZipServiceException if creation went wrong. + */ + public URL getDownloadUrl(String filename, Date expiration) throws ArchiveZipServiceException { + + if (!s3Client.doesObjectExist(csvConfig.getBucketName(), filename)) { + throw new ArchiveZipServiceException(filename, ArchiveZipServiceException.Reason.ZIP_FILE_NOT_FOUND); + } + + GeneratePresignedUrlRequest generatePresignedUrlRequest = + new GeneratePresignedUrlRequest(csvConfig.getBucketName(), filename) + .withMethod(HttpMethod.GET) + .withExpiration(expiration); + + URL presignedUrl; + try { + presignedUrl = s3Client.generatePresignedUrl(generatePresignedUrlRequest); + } catch (SdkClientException e) { + throw new ArchiveZipServiceException(e.getMessage(), ArchiveZipServiceException.Reason.UNEXPECTED_ERROR); + } + + return presignedUrl; + } + + @RequiredArgsConstructor + @Getter + public static class ArchiveZipServiceException extends Exception { + + private final String message; + private final Reason reason; + + public enum Reason { + CSV_FILE_NOT_FOUND, + ZIP_FILE_NOT_FOUND, + UNEXPECTED_ERROR + } + } + +} diff --git a/src/test/java/app/coronawarn/quicktest/controller/ArchiveExportControllerTest.java b/src/test/java/app/coronawarn/quicktest/controller/ArchiveExportControllerTest.java index 80144361..c2cae298 100644 --- a/src/test/java/app/coronawarn/quicktest/controller/ArchiveExportControllerTest.java +++ b/src/test/java/app/coronawarn/quicktest/controller/ArchiveExportControllerTest.java @@ -21,37 +21,62 @@ package app.coronawarn.quicktest.controller; import static app.coronawarn.quicktest.config.SecurityConfig.ROLE_ARCHIVE_OPERATOR; +import static app.coronawarn.quicktest.config.SecurityConfig.ROLE_ARCHIVE_ZIP_CREATOR; +import static app.coronawarn.quicktest.config.SecurityConfig.ROLE_ARCHIVE_ZIP_DOWNLOADER; import static app.coronawarn.quicktest.config.SecurityConfig.ROLE_COUNTER; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import app.coronawarn.quicktest.config.CsvUploadConfig; import app.coronawarn.quicktest.config.QuicktestKeycloakSpringBootConfigResolver; import app.coronawarn.quicktest.domain.QuickTestArchive; import app.coronawarn.quicktest.model.Sex; +import app.coronawarn.quicktest.model.cancellation.ZipRequest; import app.coronawarn.quicktest.repository.QuickTestArchiveRepository; import app.coronawarn.quicktest.service.ArchiveSchedulingService; import app.coronawarn.quicktest.utils.Utilities; +import com.amazonaws.HttpMethod; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.S3Object; import com.c4_soft.springaddons.security.oauth2.test.annotations.OpenIdClaims; import com.c4_soft.springaddons.security.oauth2.test.annotations.keycloak.WithMockKeycloakAuth; import com.c4_soft.springaddons.security.oauth2.test.mockmvc.keycloak.ServletKeycloakAuthUnitTestingSupport; +import com.fasterxml.jackson.databind.ObjectMapper; import com.opencsv.CSVParser; import com.opencsv.CSVParserBuilder; import com.opencsv.CSVReader; import com.opencsv.CSVReaderBuilder; import com.opencsv.exceptions.CsvException; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.io.StringReader; +import java.net.URL; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.time.LocalDateTime; import java.util.List; +import net.lingala.zip4j.io.inputstream.ZipInputStream; +import net.lingala.zip4j.model.LocalFileHeader; +import net.lingala.zip4j.model.enums.EncryptionMethod; import org.apache.commons.lang3.RandomUtils; import org.apache.tomcat.util.buf.HexUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.keycloak.adapters.springsecurity.KeycloakSecurityComponents; +import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Import; import org.springframework.http.HttpHeaders; @@ -61,6 +86,7 @@ import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.util.StreamUtils; @ExtendWith(SpringExtension.class) @SpringBootTest(properties = "keycloak-admin.realm=REALM") @@ -76,6 +102,15 @@ class ArchiveExportControllerTest extends ServletKeycloakAuthUnitTestingSupport @Autowired private ArchiveSchedulingService archiveSchedulingService; + @MockBean + private AmazonS3 s3Client; + + @Autowired + CsvUploadConfig csvUploadConfig; + + @Autowired + ObjectMapper objectMapper; + private final static String userId = "user-id"; public static final String PARTNER_ID_1 = "P10000"; @@ -86,10 +121,12 @@ class ArchiveExportControllerTest extends ServletKeycloakAuthUnitTestingSupport public static final String POC_ID_2 = "Poc_43"; + public static final String ZIP_PASSWORD = "abcdefghijklmnopqrstuvwqyz"; + @Test @WithMockKeycloakAuth( - authorities = ROLE_ARCHIVE_OPERATOR, - claims = @OpenIdClaims(sub = userId) + authorities = ROLE_ARCHIVE_OPERATOR, + claims = @OpenIdClaims(sub = userId) ) void downloadArchiveByPartnerId() throws Exception { @@ -105,24 +142,24 @@ void downloadArchiveByPartnerId() throws Exception { archiveSchedulingService.moveToArchiveJob(); MvcResult mvcResult = mockMvc().perform(MockMvcRequestBuilders - .get("/api/archive/" + PARTNER_ID_1)) - .andReturn(); + .get("/api/archive/" + PARTNER_ID_1)) + .andReturn(); Assertions.assertEquals(HttpStatus.OK.value(), mvcResult.getResponse().getStatus()); Assertions.assertEquals(MediaType.APPLICATION_OCTET_STREAM_VALUE, mvcResult.getResponse().getHeader(HttpHeaders.CONTENT_TYPE)); Assertions.assertEquals("attachment; filename=quicktest_export.csv", mvcResult.getResponse().getHeader(HttpHeaders.CONTENT_DISPOSITION)); checkCsv(mvcResult.getResponse().getContentAsByteArray(), 24, 5); mvcResult = mockMvc().perform(MockMvcRequestBuilders - .get("/api/archive/" + PARTNER_ID_2)) - .andReturn(); + .get("/api/archive/" + PARTNER_ID_2)) + .andReturn(); Assertions.assertEquals(HttpStatus.OK.value(), mvcResult.getResponse().getStatus()); Assertions.assertEquals(MediaType.APPLICATION_OCTET_STREAM_VALUE, mvcResult.getResponse().getHeader(HttpHeaders.CONTENT_TYPE)); Assertions.assertEquals("attachment; filename=quicktest_export.csv", mvcResult.getResponse().getHeader(HttpHeaders.CONTENT_DISPOSITION)); checkCsv(mvcResult.getResponse().getContentAsByteArray(), 24, 4); mvcResult = mockMvc().perform(MockMvcRequestBuilders - .get("/api/archive/randomPartnerId")) - .andReturn(); + .get("/api/archive/randomPartnerId")) + .andReturn(); Assertions.assertEquals(HttpStatus.OK.value(), mvcResult.getResponse().getStatus()); Assertions.assertEquals(MediaType.APPLICATION_OCTET_STREAM_VALUE, mvcResult.getResponse().getHeader(HttpHeaders.CONTENT_TYPE)); Assertions.assertEquals("attachment; filename=quicktest_export.csv", mvcResult.getResponse().getHeader(HttpHeaders.CONTENT_DISPOSITION)); @@ -132,26 +169,139 @@ void downloadArchiveByPartnerId() throws Exception { @Test @WithMockKeycloakAuth( - authorities = ROLE_COUNTER, - claims = @OpenIdClaims(sub = userId) + authorities = ROLE_COUNTER, + claims = @OpenIdClaims(sub = userId) ) void downloadArchiveWrongRole() throws Exception { mockMvc().perform(MockMvcRequestBuilders - .get("/api/archive/" + PARTNER_ID_1)) - .andExpect(status().isForbidden()); + .get("/api/archive/" + PARTNER_ID_1)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockKeycloakAuth( + authorities = ROLE_ARCHIVE_ZIP_CREATOR, + claims = @OpenIdClaims(sub = userId) + ) + void testCreateZip() throws Exception { + ZipRequest zipRequest = new ZipRequest(); + zipRequest.setPartnerId(PARTNER_ID_1); + zipRequest.setPartnerIds(List.of(PARTNER_ID_2)); + zipRequest.setPassword(ZIP_PASSWORD); + + byte[] csv1 = RandomUtils.nextBytes(1000); + byte[] csv2 = RandomUtils.nextBytes(1000); + + S3Object csv1Object = new S3Object(); + csv1Object.setObjectContent(new ByteArrayInputStream(csv1)); + + S3Object csv2Object = new S3Object(); + csv2Object.setObjectContent(new ByteArrayInputStream(csv2)); + + when(s3Client.getObject(csvUploadConfig.getBucketName(), PARTNER_ID_1 + ".csv")) + .thenReturn(csv1Object); + when(s3Client.getObject(csvUploadConfig.getBucketName(), PARTNER_ID_2 + ".csv")) + .thenReturn(csv2Object); + + ArgumentCaptor zipUploadStreamCaptor = ArgumentCaptor.forClass(InputStream.class); + ArgumentCaptor zipUploadMetaCaptor = ArgumentCaptor.forClass(ObjectMetadata.class); + + when(s3Client.putObject( + eq(csvUploadConfig.getBucketName()), + eq(PARTNER_ID_1 + ".zip"), + zipUploadStreamCaptor.capture(), + zipUploadMetaCaptor.capture())) + .thenReturn(null); + + mockMvc().perform(MockMvcRequestBuilders + .post("/api/archive/zip") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(zipRequest))) + .andExpect(status().isCreated()) + .andExpect(content().bytes(new byte[0])); + + csv1Object.close(); + csv2Object.close(); + + Assertions.assertNotNull(zipUploadMetaCaptor.getValue()); + Assertions.assertNotNull(zipUploadStreamCaptor.getValue()); + + byte[] uploadedZip = StreamUtils.copyToByteArray(zipUploadStreamCaptor.getValue()); + + ObjectMetadata uploadedMetadata = zipUploadMetaCaptor.getValue(); + Assertions.assertEquals(MediaType.APPLICATION_OCTET_STREAM_VALUE, uploadedMetadata.getContentType()); + Assertions.assertEquals(uploadedZip.length, uploadedMetadata.getContentLength()); + + ZipInputStream zipStream = new ZipInputStream(new ByteArrayInputStream(uploadedZip), ZIP_PASSWORD.toCharArray()); + + for (byte i = 0; i < 2; i++) { + System.out.println(i); + LocalFileHeader zipFile = zipStream.getNextEntry(); + Assertions.assertEquals(EncryptionMethod.AES, zipFile.getEncryptionMethod()); + + if (zipFile.getFileName().equals(PARTNER_ID_1 + ".csv")) { + Assertions.assertArrayEquals(csv1, zipStream.readAllBytes()); + } else if (zipFile.getFileName().equals(PARTNER_ID_2 + ".csv")) { + Assertions.assertArrayEquals(csv2, zipStream.readAllBytes()); + } else { + Assertions.fail(); + } + } + + Assertions.assertNull(zipStream.getNextEntry()); + + zipStream.close(); + } + + @Test + @WithMockKeycloakAuth( + authorities = ROLE_ARCHIVE_ZIP_DOWNLOADER, + claims = @OpenIdClaims(sub = userId) + ) + void testRequestPresignedUrl() throws Exception { + URL presignedUrl = new URL("https://example.org"); + + when(s3Client.doesObjectExist(csvUploadConfig.getBucketName(), PARTNER_ID_1 + ".zip")) + .thenReturn(true); + when(s3Client.doesObjectExist(csvUploadConfig.getBucketName(), PARTNER_ID_2 + ".zip")) + .thenReturn(false); + + ArgumentCaptor presignedUrlRequestArgumentCaptor = ArgumentCaptor.forClass(GeneratePresignedUrlRequest.class); + when(s3Client.generatePresignedUrl(presignedUrlRequestArgumentCaptor.capture())) + .thenReturn(presignedUrl); + + mockMvc().perform(MockMvcRequestBuilders + .get("/api/archive/zip/download?filename=" + PARTNER_ID_2 + ".zip")) + .andExpect(status().isNotFound()) + .andExpect(content().bytes(new byte[0])); + + verify(s3Client, never()).generatePresignedUrl(any()); + + mockMvc().perform(MockMvcRequestBuilders + .get("/api/archive/zip/download?filename=" + PARTNER_ID_1 + ".zip")) + .andExpect(status().isOk()) + .andExpect(content().string(objectMapper.writeValueAsString(presignedUrl))); + + long now = Instant.now().toEpochMilli(); + long expiration = presignedUrlRequestArgumentCaptor.getValue().getExpiration().toInstant().toEpochMilli(); + Assertions.assertTrue(expiration - now > 299_000); + Assertions.assertTrue(expiration - now < 301_000); + Assertions.assertEquals(csvUploadConfig.getBucketName(), presignedUrlRequestArgumentCaptor.getValue().getBucketName()); + Assertions.assertEquals(PARTNER_ID_1 + ".zip", presignedUrlRequestArgumentCaptor.getValue().getKey()); + Assertions.assertEquals(HttpMethod.GET, presignedUrlRequestArgumentCaptor.getValue().getMethod()); } private void checkCsv(byte[] csvBytes, int expectedCols, int expectedRows) throws IOException, CsvException { String csv = new String(csvBytes, StandardCharsets.UTF_8); CSVParser csvParser = new CSVParserBuilder() - .withSeparator('\t') - .build(); + .withSeparator('\t') + .build(); try (CSVReader csvReader = new CSVReaderBuilder(new StringReader(csv)) - .withCSVParser(csvParser) - .build() + .withCSVParser(csvParser) + .build() ) { List csvEntries = csvReader.readAll(); Assertions.assertEquals(expectedRows, csvEntries.size()); diff --git a/src/test/java/app/coronawarn/quicktest/service/CancellationCsvTest.java b/src/test/java/app/coronawarn/quicktest/service/CancellationCsvTest.java index 8031c3ca..9de4e186 100644 --- a/src/test/java/app/coronawarn/quicktest/service/CancellationCsvTest.java +++ b/src/test/java/app/coronawarn/quicktest/service/CancellationCsvTest.java @@ -92,7 +92,7 @@ class CancellationCsvTest { @BeforeEach void setUp() { shortTermArchiveRepository.deleteAll(); - longTermArchiveRepository.deleteAllByTenantId(PARTNER_ID); + longTermArchiveRepository.deleteAllByTenantId(PARTNER_ID_HASH); cancellationRepository.deleteAll(); }