diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 0000000..b308cd2 --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,61 @@ +name: Build, test and analyze + +on: + push: + branches: [ develop ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - run: | + git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* + + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 11.0.7 + + - name: Cache local Maven repository + uses: actions/cache@v2 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Cache SonarCloud packages + uses: actions/cache@v2 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Build and run tests + run: | + mvn clean install + env: + GITHUB_TOKEN: ${{ github.token }} + TESTCONTAINERS_RYUK_DISABLED: true + + - name: Analyze (sonar) + run: mvn sonar:sonar + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} + + - name: Create Snapshot Release + uses: ncipollo/release-action@880be3d0a71bc0fa98db60201d2cbdc27324f547 + if: github.ref == 'refs/heads/develop' + id: create_release + with: + name: Snapshot Release ${{ github.ref }} + tag: SNAPSHOT + artifacts: target/cc-api-gateway-service.jar + token: ${{ secrets.GITHUB_TOKEN }} + draft: false + prerelease: false + allowUpdates: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6030b1a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Create Release + +on: + push: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Get current time + uses: 1466587594/get-current-time@v2 + id: current-time + with: + format: YYYYMMDD_HHmmss + utcOffset: "+02:00" + - uses: actions/checkout@v2 + - run: | + git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 11.0.7 + + - name: Build with Maven + run: mvn install + env: + GITHUB_TOKEN: ${{ github.token }} + TESTCONTAINERS_RYUK_DISABLED: true + + - name: Create new release + uses: marvinpinto/action-automatic-releases@919008cf3f741b179569b7a6fb4d8860689ab7f0 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + prerelease: false + automatic_release_tag: ${{ steps.current-time.outputs.formattedTime }} + title: Release ${{ steps.current-time.outputs.formattedTime }} + files: target/cc-api-gateway-service.jar diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc93482 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/** +!**/src/test/** + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea/* +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ + +### VS Code ### +.vscode/ + +*.log +/log.log* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..398967c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Swiss Admin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b56154f --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# CovidCertificate-Printing-Service + +This service sends the pdf certificates to the printing server. + +This project is released by the the Federal Office of Information Technology, Systems and Telecommunication FOITT on behalf of the Federal Office of Public Health FOPH. diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..c5070a6 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3' +services: + db-cc-printing: + image: postgres + ports: + - "3122:5432" + environment: + - POSTGRES_USER=cc-printing + - POSTGRES_PASSWORD=secret + - POSTGRES_DB=cc-printing + sftp-cc-printing: + image: "emberstack/sftp" + ports: + - "2222:22" + volumes: + - ./sftp/config/sftp.json:/app/config/sftp.json:ro diff --git a/docker/sftp/config/sftp.json b/docker/sftp/config/sftp.json new file mode 100644 index 0000000..7179da1 --- /dev/null +++ b/docker/sftp/config/sftp.json @@ -0,0 +1,15 @@ +{ + "Global": { + "Chroot": { + "Directory": "%h", + "StartPath": "sftp" + }, + "Directories": ["sftp"] + }, + "Users": [ + { + "Username": "foo", + "Password": "pass" + } + ] +} diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..a23edb4 --- /dev/null +++ b/lombok.config @@ -0,0 +1,2 @@ +config.stopBubbling = true +lombok.addLombokGeneratedAnnotation = true \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..dfaac7a --- /dev/null +++ b/pom.xml @@ -0,0 +1,292 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.4.6 + + + ch.admin.bag.covidcertificate + cc-printing-service + 2.0.0 + cc-printing-service + Service for printing Covid Certificates + + + 11 + + 2020.0.3 + 5.2 + 2.6.1 + 24.1-jre + 1.3.6 + 0.8.5 + + true + + + **/org/**/*.java,**/com/**/*.java,**/config/security/**/*.java + + + **/*Dto.java + + + **/*Dto.java,**/config/*,**/*Exception.java,**/*Constants.java,**/*Registry.java,**/*Config.java,**/*Mock*,**/*Application.java,**/*HttpResponseHeaderFilter.java,**/*ActuatorSecurity.java + + + admin-ch_CovidCertificate-Printing-Service + admin-ch + https://sonarcloud.io + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-rest + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-integration + + + + org.springframework.integration + spring-integration-http + + + org.springframework.integration + spring-integration-jpa + + + org.springframework.integration + spring-integration-sftp + + + + + com.opencsv + opencsv + 5.4 + + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-security + + + io.micrometer + micrometer-registry-prometheus + + + + + org.springframework.cloud + spring-cloud-starter-sleuth + + + net.logstash.logback + logstash-logback-encoder + ${logstash.version} + + + org.codehaus.janino + janino + ${janino.version} + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-validation + + + + javax.servlet + javax.servlet-api + provided + + + + org.flywaydb + flyway-core + + + org.postgresql + postgresql + runtime + + + + org.apache.commons + commons-lang3 + + + + org.springdoc + springdoc-openapi-ui + ${springdoc.version} + + + org.springdoc + springdoc-openapi-webmvc-core + ${springdoc.version} + + + + org.projectlombok + lombok + + + + io.jsonwebtoken + jjwt-api + 0.11.1 + + + io.jsonwebtoken + jjwt-impl + 0.11.1 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.1 + runtime + + + + com.google.guava + guava + ${guava.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + junit-vintage-engine + org.junit.vintage + + + + + org.springframework.integration + spring-integration-test + test + + + org.mockito + mockito-inline + 3.8.0 + test + + + com.flextrade.jfixture + jfixture-mockito + 2.7.2 + test + + + com.h2database + h2 + test + + + net.therore.logback + therore-logback + 1.0.0 + test + + + com.github.tomakehurst + wiremock-standalone + 2.16.0 + test + + + + + cc-printing-service + + + org.springframework.boot + spring-boot-maven-plugin + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + agent + + prepare-agent + + + + report + + report + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + + diff --git a/src/main/java/ch/admin/bag/covidcertificate/CCPrintingServiceApplication.java b/src/main/java/ch/admin/bag/covidcertificate/CCPrintingServiceApplication.java new file mode 100644 index 0000000..a5b106c --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/CCPrintingServiceApplication.java @@ -0,0 +1,40 @@ +package ch.admin.bag.covidcertificate; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.ServletComponentScan; +import org.springframework.core.env.Environment; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +@ServletComponentScan +@SpringBootApplication +@EnableConfigurationProperties +@EnableScheduling +@EnableAsync +@Slf4j +public class CCPrintingServiceApplication { + + public static void main(String[] args) { + + Environment env = SpringApplication.run(CCPrintingServiceApplication.class, args).getEnvironment(); + + String protocol = "http"; + if (env.getProperty("server.ssl.key-store") != null) { + protocol = "https"; + } + log.info("\n----------------------------------------------------------\n\t" + + "Yeah!!! {} is running! \n\t" + + "\n" + + "\tSwaggerUI: \t{}://localhost:{}/swagger-ui.html\n\t" + + "Profile(s): \t{}" + + "\n----------------------------------------------------------", + env.getProperty("spring.application.name"), + protocol, + env.getProperty("server.port"), + env.getActiveProfiles()); + + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/api/CertificatePrintQueueItemMapper.java b/src/main/java/ch/admin/bag/covidcertificate/api/CertificatePrintQueueItemMapper.java new file mode 100644 index 0000000..8edf4be --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/api/CertificatePrintQueueItemMapper.java @@ -0,0 +1,24 @@ +package ch.admin.bag.covidcertificate.api; + +import ch.admin.bag.covidcertificate.domain.CertificatePrintQueueItem; +import ch.admin.bag.covidcertificate.domain.CertificatePrintStatus; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CertificatePrintQueueItemMapper { + + public static CertificatePrintQueueItem create(CertificatePrintRequestDto printCertificateRequestDto){ + return new CertificatePrintQueueItem( + printCertificateRequestDto.getUvci(), + CertificatePrintStatus.CREATED.name(), + printCertificateRequestDto.getAddressLine1(), + printCertificateRequestDto.getAddressLine2(), + printCertificateRequestDto.getZipCode(), + printCertificateRequestDto.getCity(), + printCertificateRequestDto.getLanguage(), + printCertificateRequestDto.getCantonCodeSender(), + printCertificateRequestDto.getPdfCertificate() + ); + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/api/CertificatePrintRequestDto.java b/src/main/java/ch/admin/bag/covidcertificate/api/CertificatePrintRequestDto.java new file mode 100644 index 0000000..5da19e0 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/api/CertificatePrintRequestDto.java @@ -0,0 +1,25 @@ +package ch.admin.bag.covidcertificate.api; + +import lombok.*; + +import javax.validation.constraints.NotNull; + +@Getter +@ToString +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EqualsAndHashCode +public class CertificatePrintRequestDto { + @NotNull + private byte[] pdfCertificate; + + @NotNull + private String uvci; + + private String addressLine1; + private String addressLine2; + private int zipCode; + private String city; + private String language; + private String cantonCodeSender; +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/ProfileRegistry.java b/src/main/java/ch/admin/bag/covidcertificate/config/ProfileRegistry.java new file mode 100644 index 0000000..58ef00b --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/ProfileRegistry.java @@ -0,0 +1,16 @@ +package ch.admin.bag.covidcertificate.config; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * use this class to document your spring profile or document it in confluence. but... document it! + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ProfileRegistry { + /** + * if activated, use a pkcs12 based mock otherwise use hsm-based security + */ + public static final String PROFILE_SFTP_MOCK = "sftp-mock"; +} + diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/RestConfig.java b/src/main/java/ch/admin/bag/covidcertificate/config/RestConfig.java new file mode 100644 index 0000000..01e7bfc --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/RestConfig.java @@ -0,0 +1,27 @@ +package ch.admin.bag.covidcertificate.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +@Configuration +public class RestConfig { + + @Value("${cc-printing-service.rest.connectTimeoutSeconds}") + private int connectTimeout; + + @Value("${cc-printing-service.rest.readTimeoutSeconds}") + private int readTimeout; + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder + .setConnectTimeout(Duration.ofSeconds(connectTimeout)) + .setReadTimeout(Duration.ofSeconds(readTimeout)) + .build(); + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/SftpConfig.java b/src/main/java/ch/admin/bag/covidcertificate/config/SftpConfig.java new file mode 100644 index 0000000..cf799ae --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/SftpConfig.java @@ -0,0 +1,59 @@ +package ch.admin.bag.covidcertificate.config; + +import com.jcraft.jsch.ChannelSftp; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.integration.annotation.Gateway; +import org.springframework.integration.annotation.MessagingGateway; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.integration.file.remote.session.CachingSessionFactory; +import org.springframework.integration.file.remote.session.SessionFactory; +import org.springframework.integration.sftp.outbound.SftpMessageHandler; +import org.springframework.integration.sftp.session.DefaultSftpSessionFactory; +import org.springframework.messaging.MessageHandler; + +import java.io.File; + +@Configuration +@Profile("!"+ProfileRegistry.PROFILE_SFTP_MOCK) +public class SftpConfig { + @Value("${bbl.sftp.host}") + private String sftpServer; + @Value("${bbl.sftp.port}") + private int sftpPort; + @Value("${bbl.sftp.user}") + private String sftpUser; + @Value("${bbl.sftp.password}") + private String sftpPassword; + + + @Bean + public SessionFactory sftpSessionFactory() { + DefaultSftpSessionFactory factory = new DefaultSftpSessionFactory(true); + factory.setHost(sftpServer); + factory.setPort(sftpPort); + factory.setUser(sftpUser); + factory.setPassword(sftpPassword); + factory.setAllowUnknownKeys(true); + return new CachingSessionFactory<>(factory); + } + + @Bean + @ServiceActivator(inputChannel = "toSftpChannel") + public MessageHandler handler() { + SftpMessageHandler handler = new SftpMessageHandler(sftpSessionFactory()); + handler.setRemoteDirectoryExpressionString("headers['remote-target-dir']"); + + return handler; + } + + @MessagingGateway + public interface PrintingServiceSftpGateway { + + @Gateway(requestChannel = "toSftpChannel") + void sendToSftp(File file); + + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/SftpMockConfig.java b/src/main/java/ch/admin/bag/covidcertificate/config/SftpMockConfig.java new file mode 100644 index 0000000..8b5c437 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/SftpMockConfig.java @@ -0,0 +1,31 @@ +package ch.admin.bag.covidcertificate.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.integration.annotation.Gateway; +import org.springframework.integration.annotation.MessagingGateway; +import org.springframework.integration.annotation.ServiceActivator; +import org.springframework.integration.handler.LoggingHandler; +import org.springframework.messaging.MessageHandler; + +import java.io.File; + +@Configuration +@Profile(ProfileRegistry.PROFILE_SFTP_MOCK) +public class SftpMockConfig { + + @Bean + @ServiceActivator(inputChannel = "toSftpChannel") + public MessageHandler handler() { + return new LoggingHandler(LoggingHandler.Level.INFO); + } + + @MessagingGateway + public interface PrintingServiceSftpGateway { + + @Gateway(requestChannel = "toSftpChannel") + void sendToSftp(File file); + + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/security/AuthorizationServerConfigProperties.java b/src/main/java/ch/admin/bag/covidcertificate/config/security/AuthorizationServerConfigProperties.java new file mode 100644 index 0000000..a41121f --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/security/AuthorizationServerConfigProperties.java @@ -0,0 +1,22 @@ +package ch.admin.bag.covidcertificate.config.security; + +import com.nimbusds.oauth2.sdk.util.StringUtils; +import lombok.Data; + +/** + * Configuration properties to configure the authorization server that the OAuth2 resource server will accept tokens from. + */ +@Data +public class AuthorizationServerConfigProperties { + + private static final String JWK_SET_URI_SUBPATH = "/protocol/openid-connect/certs"; + + private String issuer; + + private String jwkSetUri; + + public String getJwkSetUri() { + return StringUtils.isNotBlank(jwkSetUri) ? jwkSetUri : issuer + JWK_SET_URI_SUBPATH; + } +} + diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/security/DefaultDenyAllWebSecurityConfiguration.java b/src/main/java/ch/admin/bag/covidcertificate/config/security/DefaultDenyAllWebSecurityConfiguration.java new file mode 100644 index 0000000..20f005f --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/security/DefaultDenyAllWebSecurityConfiguration.java @@ -0,0 +1,47 @@ +package ch.admin.bag.covidcertificate.config.security; + +import ch.admin.bag.covidcertificate.config.security.authentication.ServletJeapAuthorization; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; + +/** + * If the security starter has been added as a dependency but the OAuth2 secured web configuration has not been + * activated e.g. because the needed configuration properties are missing, then deny access to all web endpoints as a + * secure default web security configuration. + */ +@Configuration +@Slf4j +@AutoConfigureAfter(OAuth2SecuredWebConfiguration.class) +public class DefaultDenyAllWebSecurityConfiguration { + + private static final String DENY_ALL_MESSAGE = "jeap-spring-boot-security-starter did not activate OAuth2 resource security " + + "for web endpoints. Activating a 'deny-all' configuration as secure fallback. " + + "Override the 'deny-all' configuration with your own web security configuration " + + "or define the configuration properties needed for the OAuth2 resource security."; + + @Configuration + @Order(500) + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + @ConditionalOnMissingBean(ServletJeapAuthorization.class) + @EnableWebSecurity + public static class WebMvcDenyAllWebSecurityConfiguration extends WebSecurityConfigurerAdapter { + + @Override + protected void configure(HttpSecurity http) throws Exception { + log.debug(DENY_ALL_MESSAGE); + http.authorizeRequests().anyRequest().denyAll(). + and(). + exceptionHandling().authenticationEntryPoint((new HttpStatusEntryPoint(HttpStatus.FORBIDDEN))); + } + } + +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/security/OAuth2SecuredWebConfiguration.java b/src/main/java/ch/admin/bag/covidcertificate/config/security/OAuth2SecuredWebConfiguration.java new file mode 100644 index 0000000..1fb3f2d --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/security/OAuth2SecuredWebConfiguration.java @@ -0,0 +1,124 @@ +package ch.admin.bag.covidcertificate.config.security; + +import ch.admin.bag.covidcertificate.config.security.authentication.JeapAuthenticationContext; +import ch.admin.bag.covidcertificate.config.security.authentication.JeapAuthenticationConverter; +import ch.admin.bag.covidcertificate.config.security.authentication.ServletJeapAuthorization; +import ch.admin.bag.covidcertificate.config.security.validation.AudienceJwtValidator; +import ch.admin.bag.covidcertificate.config.security.validation.ContextIssuerJwtValidator; +import ch.admin.bag.covidcertificate.config.security.validation.JeapJwtDecoderFactory; +import com.nimbusds.oauth2.sdk.util.StringUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; + +import java.time.Duration; +import java.util.EnumMap; +import java.util.Map; + +@Configuration +@ConditionalOnProperty("jeap.security.oauth2.resourceserver.authorization-server.issuer") +@Slf4j +public class OAuth2SecuredWebConfiguration { + + @Configuration + @EnableConfigurationProperties({ResourceServerConfigProperties.class}) + public static class OAuth2SecuredWebCommonConfigurationProperties { + + private String applicationName; + private ResourceServerConfigProperties resourceServer; + + public OAuth2SecuredWebCommonConfigurationProperties( + @Value("${spring.application.name}") String applicationName, + ResourceServerConfigProperties resourceServer) { + this.applicationName = applicationName; + this.resourceServer = resourceServer; + } + + public String getResourceIdWithFallbackToApplicationName() { + return StringUtils.isNotBlank(resourceServer.getResourceId()) ? resourceServer.getResourceId() : applicationName; + } + + public ResourceServerConfigProperties getResourceServer() { + return resourceServer; + } + } + + @Configuration + @Order(499) + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + @EnableWebSecurity + @EnableGlobalMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true) + @RequiredArgsConstructor + public static class OAuth2SecuredWebMvcConfiguration extends WebSecurityConfigurerAdapter { + + private final OAuth2SecuredWebCommonConfigurationProperties commonConfiguration; + + @Override + public void configure(HttpSecurity http) throws Exception { + + //All requests must be authenticated + http.authorizeRequests() + .anyRequest() + .fullyAuthenticated(); + + //Enable CORS + http.cors(); + + //Enable CSRF with CookieCsrfTokenRepository as can be used from Angular + http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()); + + //No session management is needed, we want stateless + http.sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS); + + //Treat endpoints as OAuth2 resources + http.oauth2ResourceServer(). + jwt(). + decoder(createJwtDecoder()). + jwtAuthenticationConverter(new JeapAuthenticationConverter()); + } + + @Bean + public ServletJeapAuthorization jeapAuthorization() { + return new ServletJeapAuthorization(); + } + + private JwtDecoder createJwtDecoder() { + final String authorizationJwkSetUri = commonConfiguration.getResourceServer().getAuthorizationServer().getJwkSetUri(); + return JeapJwtDecoderFactory.createJwtDecoder(authorizationJwkSetUri, createTokenValidator(commonConfiguration)); + } + } + + static OAuth2TokenValidator createTokenValidator(OAuth2SecuredWebCommonConfigurationProperties commonConfiguration) { + return new DelegatingOAuth2TokenValidator<>( + new JwtTimestampValidator(Duration.ofSeconds(30)), + new AudienceJwtValidator(commonConfiguration.getResourceIdWithFallbackToApplicationName()), + createContextIssuerJwtValidator(commonConfiguration.getResourceServer()) + ); + } + + static ContextIssuerJwtValidator createContextIssuerJwtValidator(ResourceServerConfigProperties resourceServerConfigProperties) { + Map contextIssuers = new EnumMap<>(JeapAuthenticationContext.class); + final String authorizationServer = resourceServerConfigProperties.getAuthorizationServer().getIssuer(); + contextIssuers.put(JeapAuthenticationContext.USER, authorizationServer); + return new ContextIssuerJwtValidator(contextIssuers); + } + +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/security/ResourceServerConfigProperties.java b/src/main/java/ch/admin/bag/covidcertificate/config/security/ResourceServerConfigProperties.java new file mode 100644 index 0000000..0248052 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/security/ResourceServerConfigProperties.java @@ -0,0 +1,16 @@ +package ch.admin.bag.covidcertificate.config.security; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties to configure the OAuth2 resource server. + */ +@Setter +@Getter +@ConfigurationProperties("jeap.security.oauth2.resourceserver") +public class ResourceServerConfigProperties { + private String resourceId; + private AuthorizationServerConfigProperties authorizationServer; +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/security/authentication/JeapAuthenticationContext.java b/src/main/java/ch/admin/bag/covidcertificate/config/security/authentication/JeapAuthenticationContext.java new file mode 100644 index 0000000..0a9a6ee --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/security/authentication/JeapAuthenticationContext.java @@ -0,0 +1,26 @@ +package ch.admin.bag.covidcertificate.config.security.authentication; + +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * The supported authentication contexts. + */ +public enum JeapAuthenticationContext { + + USER; + + private static final String CONTEXT_CLAIM_NAME = "ctx"; + + public static JeapAuthenticationContext readFromJwt(Jwt jwt) { + String context = jwt.getClaimAsString(CONTEXT_CLAIM_NAME); + if(context == null) { + throw new IllegalArgumentException("Context claim '" + CONTEXT_CLAIM_NAME + "' is missing from the JWT."); + } + return JeapAuthenticationContext.valueOf(context); + } + + public static String getContextJwtClaimName() { + return CONTEXT_CLAIM_NAME; + } + +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/security/authentication/JeapAuthenticationConverter.java b/src/main/java/ch/admin/bag/covidcertificate/config/security/authentication/JeapAuthenticationConverter.java new file mode 100644 index 0000000..9fe00e6 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/security/authentication/JeapAuthenticationConverter.java @@ -0,0 +1,52 @@ +package ch.admin.bag.covidcertificate.config.security.authentication; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.util.*; + + +@Slf4j +public class JeapAuthenticationConverter implements Converter { + + private static final String USER_ROLES_CLAIM = "userroles"; + + @Override + public AbstractAuthenticationToken convert(@NonNull Jwt jwt) { + return new JeapAuthenticationToken(jwt, extractUserRoles(jwt)); + } + + private Set extractUserRoles(Jwt jwt) { + List userrolesClaim = Optional.of(jwt) + .map(Jwt::getClaims) + .flatMap(map -> getIfPossible(map, USER_ROLES_CLAIM, List.class)) + .orElse(Collections.emptyList()); + + Set userRoles = new HashSet<>(); + userrolesClaim.forEach( userroleObject -> { + try { + userRoles.add((String) userroleObject); + } catch (ClassCastException e) { + log.warn("Ignoring non String user role."); + } + }); + + return userRoles; + } + + private Optional getIfPossible(Map map, String key, Class klass) { + Object value = map.get(key); + if (value == null) { + return Optional.empty(); + } + try { + return Optional.of(klass.cast(value)); + } catch (ClassCastException e) { + log.warn("Unable to map value of entry {} to class {}, ignoring the entry.", key, klass.getSimpleName()); + return Optional.empty(); + } + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/security/authentication/JeapAuthenticationToken.java b/src/main/java/ch/admin/bag/covidcertificate/config/security/authentication/JeapAuthenticationToken.java new file mode 100644 index 0000000..77a3576 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/security/authentication/JeapAuthenticationToken.java @@ -0,0 +1,123 @@ +package ch.admin.bag.covidcertificate.config.security.authentication; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.util.Collection; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +public class JeapAuthenticationToken extends JwtAuthenticationToken { + + private static final String ROLE_PREFIX = "ROLE_"; + + private final Set userRoles; + + public JeapAuthenticationToken(Jwt jwt, Set userRoles) { + super(jwt, deriveAuthoritiesFromRoles(userRoles)); + this.userRoles = Collections.unmodifiableSet(userRoles); + } + + /** + * Get the client id specified in this token. + * + * @return The client id specified in this token. + */ + public String getClientId() { + return getToken().getClaimAsString("clientId"); + } + + /** + * Get the name specified in this token. + * + * @return The name specified in this token. + */ + public String getTokenName() { + return getToken().getClaimAsString("name"); + } + + /** + * Get the given name specified in this token. + * + * @return The given name specified in this token. + */ + public String getTokenGivenName() { + return getToken().getClaimAsString("given_name"); + } + + /** + * Get the family name specified in this token. + * + * @return The family name specified in this token. + */ + public String getTokenFamilyName() { + return getToken().getClaimAsString("family_name"); + } + + /** + * Get the subject specified in this token. + * + * @return The subject specified in this token. + */ + public String getTokenSubject() { + return getToken().getClaimAsString("sub"); + } + + /** + * Get the locale specified in this token. + * + * @return The locale specified in this token. + */ + public String getTokenLocale() { + return getToken().getClaimAsString("locale"); + } + + /** + * Get the jeap authentication context specified in this token. + * + * @return The jeap authentication context specified in this token. + */ + public JeapAuthenticationContext getJeapAuthenticationContext() { + return JeapAuthenticationContext.readFromJwt(getToken()); + } + + /** + * Get the user roles listed in this token. + * + * @return The user roles + */ + public Set getUserRoles() { + return userRoles; + } + + @Override + public String toString() { + return String.format( + "JeapAuthenticationToken{ subject (calling user): %s, client (calling system): %s, authorities (all roles): %s, user roles: %s}", + getName(), getClientId(), authoritiesToString(), userRolesToString()); + } + + private static Collection deriveAuthoritiesFromRoles(Set userRoles) { + return userRoles.stream().map(s -> ROLE_PREFIX + s) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toSet()); + } + + private String authoritiesToString() { + return getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .map(a -> "'" + a + "'") + .collect(Collectors.joining(",")); + } + + + private String userRolesToString() { + return getUserRoles().stream(). + map( r -> "'" + r + "'"). + collect(Collectors.joining(", ")); + } + +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/security/authentication/ServletJeapAuthorization.java b/src/main/java/ch/admin/bag/covidcertificate/config/security/authentication/ServletJeapAuthorization.java new file mode 100644 index 0000000..762dc0a --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/security/authentication/ServletJeapAuthorization.java @@ -0,0 +1,19 @@ +package ch.admin.bag.covidcertificate.config.security.authentication; + +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * This class provides methods to support authorization needs based on the current security context for Spring WebMvc applications. + */ +public class ServletJeapAuthorization { + + /** + * Fetch the JeapAuthenticationToken from the current security context. + * + * @return The JeapAuthenticationToken extracted from the current security context. + */ + public JeapAuthenticationToken getJeapAuthenticationToken() { + return (JeapAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); + } + +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/AudienceJwtValidator.java b/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/AudienceJwtValidator.java new file mode 100644 index 0000000..ea4025c --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/AudienceJwtValidator.java @@ -0,0 +1,32 @@ +package ch.admin.bag.covidcertificate.config.security.validation; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +@Slf4j +public class AudienceJwtValidator implements OAuth2TokenValidator { + + private final String audience; + private final OAuth2Error error; + + public AudienceJwtValidator(String audience) { + this.audience = audience; + this.error = new OAuth2Error("invalid_token", "The token is is not valid for audience '" + audience + "'.", null); + } + + public OAuth2TokenValidatorResult validate(Jwt jwt) { + if(jwt.getAudience() == null || jwt.getAudience().isEmpty()) { + //If audience is missing this means token is valid for every system + return OAuth2TokenValidatorResult.success(); + } + if (jwt.getAudience().contains(audience)) { + return OAuth2TokenValidatorResult.success(); + } else { + log.warn(error.getDescription()); + return OAuth2TokenValidatorResult.failure(error); + } + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/ContextIssuerJwtValidator.java b/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/ContextIssuerJwtValidator.java new file mode 100644 index 0000000..cffa727 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/ContextIssuerJwtValidator.java @@ -0,0 +1,54 @@ +package ch.admin.bag.covidcertificate.config.security.validation; + +import ch.admin.bag.covidcertificate.config.security.authentication.JeapAuthenticationContext; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtIssuerValidator; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; + +@Slf4j +/** + * This class implements a JwtValidator that checks if the access token for a given context has been issued by + * a given issuer. + */ +public class ContextIssuerJwtValidator implements OAuth2TokenValidator { + + private final Map> contextValidatorMap; + + public ContextIssuerJwtValidator(Map contextIssuerMap) { + Map> hashMap = new EnumMap<>(JeapAuthenticationContext.class); + for(Map.Entry context : contextIssuerMap.entrySet()) { + JwtIssuerValidator validator = new JwtIssuerValidator(context.getValue()); + hashMap.put(context.getKey(), validator); + } + this.contextValidatorMap = Collections.unmodifiableMap(hashMap); + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + try { + JeapAuthenticationContext context = JeapAuthenticationContext.readFromJwt(jwt); + OAuth2TokenValidator contextIssuerJwtValidator = contextValidatorMap.get(context); + if (contextIssuerJwtValidator != null) { + return contextIssuerJwtValidator.validate(jwt); + } else { + return createErrorResult("Unsupported context claim value '" + context + "'."); + } + } catch (IllegalArgumentException e) { + //This is the case if the context is not valid + return createErrorResult(e.getMessage()); + } + } + + private OAuth2TokenValidatorResult createErrorResult(String errorMessage) { + OAuth2Error error = new OAuth2Error("invalid_token", errorMessage, null); + log.warn(error.getDescription()); + return OAuth2TokenValidatorResult.failure(error); + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/JeapJwtDecoder.java b/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/JeapJwtDecoder.java new file mode 100644 index 0000000..4de9870 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/JeapJwtDecoder.java @@ -0,0 +1,17 @@ +package ch.admin.bag.covidcertificate.config.security.validation; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; + +@RequiredArgsConstructor +class JeapJwtDecoder implements JwtDecoder { + + private final JwtDecoder authenticationServerJwtDecoder; + + @Override + public Jwt decode(String token) { + return authenticationServerJwtDecoder.decode(token); + } + +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/JeapJwtDecoderFactory.java b/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/JeapJwtDecoderFactory.java new file mode 100644 index 0000000..a555211 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/JeapJwtDecoderFactory.java @@ -0,0 +1,28 @@ +package ch.admin.bag.covidcertificate.config.security.validation; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class JeapJwtDecoderFactory { + + public static JwtDecoder createJwtDecoder(String authorizationServerJwkSetUri, OAuth2TokenValidator jwtValidator) { + return createDefaultJwtDecoder(authorizationServerJwkSetUri, jwtValidator); + } + + private static JwtDecoder createDefaultJwtDecoder(String jwkSetUri, OAuth2TokenValidator jwtValidator) { + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder. + withJwkSetUri(jwkSetUri). + jwsAlgorithm(SignatureAlgorithm.RS256). + jwsAlgorithm(SignatureAlgorithm.RS512). + build(); + jwtDecoder.setJwtValidator(jwtValidator); + return jwtDecoder; + } + +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/RawJwtTokenParser.java b/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/RawJwtTokenParser.java new file mode 100644 index 0000000..09e8da3 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/config/security/validation/RawJwtTokenParser.java @@ -0,0 +1,30 @@ +package ch.admin.bag.covidcertificate.config.security.validation; + +import ch.admin.bag.covidcertificate.config.security.authentication.JeapAuthenticationContext; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTParser; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +class RawJwtTokenParser { + + static JeapAuthenticationContext extractAuthenticationContext(String token) { + try { + JWT jwt = parse(token); + String contextClaimValue = jwt.getJWTClaimsSet().getStringClaim(JeapAuthenticationContext.getContextJwtClaimName()); + return JeapAuthenticationContext.valueOf(contextClaimValue); + } + catch (Exception e) { + throw new IllegalArgumentException(String.format("No valid authentication context extractable from JWT: %s.", e.getMessage()), e); + } + } + + private static JWT parse(String token) { + try { + return JWTParser.parse(token); + } catch (Exception ex) { + throw new IllegalArgumentException(String.format("Unparseable JWT: %s", ex.getMessage()), ex); + } + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePdfData.java b/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePdfData.java new file mode 100644 index 0000000..7a1cad2 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePdfData.java @@ -0,0 +1,34 @@ +package ch.admin.bag.covidcertificate.domain; + +import lombok.*; +import lombok.extern.slf4j.Slf4j; + +import javax.persistence.*; +import javax.validation.constraints.NotNull; +import java.util.UUID; + +@Entity +@Table(name = "certificate_pdf_data") +@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA +@AllArgsConstructor +@Getter +@Slf4j +@ToString +public class CertificatePdfData { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private UUID id; + + @NotNull + private byte[] pdf; + + @OneToOne + @JoinColumn(name = "certificate_pdf_print_queue_item_id") + private CertificatePrintQueueItem certificatePrintQueueItem; + + public CertificatePdfData(@NotNull byte[] pdf, @NotNull CertificatePrintQueueItem certificatePrintQueueItem) { + this.pdf = pdf; + this.certificatePrintQueueItem = certificatePrintQueueItem; + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintMetadata.java b/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintMetadata.java new file mode 100644 index 0000000..026821c --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintMetadata.java @@ -0,0 +1,44 @@ +package ch.admin.bag.covidcertificate.domain; + +import com.opencsv.bean.CsvBindByPosition; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class CertificatePrintMetadata { + @CsvBindByPosition(position = 0) + private String uvci; + + @CsvBindByPosition(position = 1) + private String addressLine1; + + @CsvBindByPosition(position = 2) + private String addressLine2; + + @CsvBindByPosition(position = 3) + private String addressLine3; + + @CsvBindByPosition(position = 4) + private String addressLine4; + + @CsvBindByPosition(position = 5) + private String zipCode; + + @CsvBindByPosition(position = 6) + private String city; + + @CsvBindByPosition(position = 7) + private String sender; + + @CsvBindByPosition(position = 8) + private String language; + + @CsvBindByPosition(position = 9) + private String prio; + + @CsvBindByPosition(position = 10) + private String filename; +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintMetadataMapper.java b/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintMetadataMapper.java new file mode 100644 index 0000000..6f2905b --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintMetadataMapper.java @@ -0,0 +1,57 @@ +package ch.admin.bag.covidcertificate.domain; + +import com.opencsv.bean.CsvToBeanBuilder; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.io.IOException; +import java.io.Reader; +import java.nio.file.Files; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static ch.admin.bag.covidcertificate.domain.UvciUtils.mapFilenameFromUVCI; + +@Component +public class CertificatePrintMetadataMapper { + private static Map cantonToSenderAddressMap; + + @PostConstruct + private static void init() throws IOException { + try (Reader reader = Files.newBufferedReader(new ClassPathResource("senderAddress.csv").getFile().toPath())) { + var csvToBean = new CsvToBeanBuilder(reader) + .withType(SenderAddressCsvData.class) + .withIgnoreLeadingWhiteSpace(true) + .withSeparator(';') + .build(); + + cantonToSenderAddressMap = csvToBean.stream() + .collect(Collectors.toMap(SenderAddressCsvData::getCantonCode, SenderAddressCsvData::getAddress)); + } + } + + public List mapAll(Collection certificatePrintQueueItems){ + return certificatePrintQueueItems.stream() + .map(this::map) + .collect(Collectors.toList()); + } + + private CertificatePrintMetadata map(CertificatePrintQueueItem certificatePrintQueueItem){ + return new CertificatePrintMetadata( + certificatePrintQueueItem.getUvci(), + certificatePrintQueueItem.getAddressLine1(), + certificatePrintQueueItem.getAddressLine2(), + null, + null, + String.valueOf(certificatePrintQueueItem.getZipCode()), + certificatePrintQueueItem.getCity(), + cantonToSenderAddressMap.get(certificatePrintQueueItem.getCantonCodeSender().toUpperCase()), + certificatePrintQueueItem.getLanguage(), + "1", + mapFilenameFromUVCI(certificatePrintQueueItem.getUvci()) + ); + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintQueueItem.java b/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintQueueItem.java new file mode 100644 index 0000000..0af7d67 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintQueueItem.java @@ -0,0 +1,69 @@ +package ch.admin.bag.covidcertificate.domain; + +import lombok.*; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.annotations.Cascade; + +import javax.persistence.*; +import javax.validation.constraints.NotNull; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.hibernate.annotations.CascadeType.PERSIST; + +@Entity +@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"uvci"}, name = "certificate_print_queue_item")}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA +@AllArgsConstructor +@Getter +@Slf4j +@ToString(exclude = "certificatePdfData") +public class CertificatePrintQueueItem { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private UUID id; + + @NotNull + private String uvci; + + @NotNull + @Setter + private String status; + + private String addressLine1; + private String addressLine2; + private Integer zipCode; + private String city; + private String language; + private String cantonCodeSender; + @Setter + private Integer errorCount; + + @OneToOne(mappedBy = "certificatePrintQueueItem") + @Cascade(PERSIST) + private CertificatePdfData certificatePdfData; + + @Column(name = "created_at", insertable = false) + private LocalDateTime createdAt; + + @Setter + @Column(name = "modified_at", insertable = false) + private LocalDateTime modifiedAt; + + + public CertificatePrintQueueItem(String uvci, String status, + String addressLine1, String addressLine2, Integer zipCode, String city, + String language, String cantonCodeSender, byte[] pdfData) { + this.uvci = uvci; + this.status = status; + this.addressLine1 = addressLine1; + this.addressLine2 = addressLine2; + this.zipCode = zipCode; + this.city = city; + this.language = language; + this.cantonCodeSender = cantonCodeSender; + this.errorCount = 0; + this.certificatePdfData = new CertificatePdfData(pdfData, this); + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintQueueRepository.java b/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintQueueRepository.java new file mode 100644 index 0000000..2ffae5c --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintQueueRepository.java @@ -0,0 +1,35 @@ +package ch.admin.bag.covidcertificate.domain; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Repository +public interface CertificatePrintQueueRepository extends JpaRepository, JpaSpecificationExecutor { + + @Query("SELECT printItem " + + "FROM CertificatePrintQueueItem printItem " + + "WHERE printItem.status = 'CREATED' " + + "AND printItem.createdAt <= :createdBefore") + Page getNotProcessedItems(@Param("createdBefore") LocalDateTime createdBefore, Pageable pageable); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM CertificatePrintQueueItem item " + + "WHERE item.status = 'PROCESSED' " + + "AND item.modifiedAt < :modifiedBefore") + int deleteItemsProcessedBeforeTimestamp(@Param("modifiedBefore") LocalDateTime modifiedBefore); + + @Modifying(clearAutomatically = true) + @Query("UPDATE CertificatePrintQueueItem item " + + "SET item.status = 'CREATED', item.errorCount = 0" + + "WHERE item.status = 'ERROR' ") + int updateFailedAndResetErrorCount(); +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintStatus.java b/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintStatus.java new file mode 100644 index 0000000..981b433 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/domain/CertificatePrintStatus.java @@ -0,0 +1,7 @@ +package ch.admin.bag.covidcertificate.domain; + +public enum CertificatePrintStatus { + CREATED, + PROCESSED, + ERROR +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/domain/SenderAddressCsvData.java b/src/main/java/ch/admin/bag/covidcertificate/domain/SenderAddressCsvData.java new file mode 100644 index 0000000..cf4f774 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/domain/SenderAddressCsvData.java @@ -0,0 +1,17 @@ +package ch.admin.bag.covidcertificate.domain; + +import com.opencsv.bean.CsvBindByName; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SenderAddressCsvData { + @CsvBindByName(column = "CantonCode", required = true) + private String cantonCode; + + @CsvBindByName(column = "Address", required = true) + private String address; +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/domain/UvciUtils.java b/src/main/java/ch/admin/bag/covidcertificate/domain/UvciUtils.java new file mode 100644 index 0000000..748740c --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/domain/UvciUtils.java @@ -0,0 +1,13 @@ +package ch.admin.bag.covidcertificate.domain; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class UvciUtils { + public static String mapFilenameFromUVCI(String uvci) { + var uvciParts = uvci.split(":"); + var filename = uvciParts[uvciParts.length - 1]; + return filename + ".pdf"; + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/service/CertificatePrintService.java b/src/main/java/ch/admin/bag/covidcertificate/service/CertificatePrintService.java new file mode 100644 index 0000000..1290708 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/service/CertificatePrintService.java @@ -0,0 +1,78 @@ +package ch.admin.bag.covidcertificate.service; + +import ch.admin.bag.covidcertificate.domain.CertificatePrintQueueItem; +import ch.admin.bag.covidcertificate.domain.CertificatePrintQueueRepository; +import ch.admin.bag.covidcertificate.domain.CertificatePrintStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.PostConstruct; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Objects; + +@Service +@Slf4j +@RequiredArgsConstructor +public class CertificatePrintService { + private final CertificatePrintQueueRepository certificatePrintQueueRepository; + + @Value("${cc-printing-service.print-queue.max-error-count}") + private int maxErrorCount; + + public Page getNotProcessedItems(LocalDateTime createdBeforeTimestamp, Integer size) { + Pageable pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.ASC, "createdAt")); + + return certificatePrintQueueRepository.getNotProcessedItems(createdBeforeTimestamp, pageable); + } + + public void saveCertificateInPrintQueue(CertificatePrintQueueItem certificatePrintQueueItem){ + certificatePrintQueueRepository.saveAndFlush(certificatePrintQueueItem); + } + + public void increaseErrorCount(Collection certificatePrintQueueItems){ + log.info("Increasing error count for {} certificates", certificatePrintQueueItems.size()); + certificatePrintQueueItems.forEach(this::increaseErrorCount); + certificatePrintQueueRepository.saveAll(certificatePrintQueueItems); + } + + private void increaseErrorCount(CertificatePrintQueueItem certificatePrintQueueItem){ + certificatePrintQueueItem.setErrorCount(certificatePrintQueueItem.getErrorCount()+1); + certificatePrintQueueItem.setModifiedAt(LocalDateTime.now()); + if(Objects.equals(certificatePrintQueueItem.getErrorCount(), maxErrorCount)){ + log.info("Printing certificate {} has failed too many times. Times: {}.", certificatePrintQueueItem.getUvci(), maxErrorCount); + certificatePrintQueueItem.setStatus(CertificatePrintStatus.ERROR.name()); + } + } + + public void updateStatus(Collection certificatePrintQueueItems, CertificatePrintStatus status){ + log.info("Updating status {} for {} certificates", status.name(), certificatePrintQueueItems.size()); + certificatePrintQueueItems.forEach(it -> { + it.setStatus(status.name()); + it.setModifiedAt(LocalDateTime.now()); + }); + certificatePrintQueueRepository.saveAll(certificatePrintQueueItems); + } + + @Transactional + public void deleteProcessedCertificatesModifiedUntilDate(LocalDateTime dateTime){ + log.info("Deleting certificates processed before {}", dateTime); + int deletedRowCount = certificatePrintQueueRepository.deleteItemsProcessedBeforeTimestamp(dateTime); + log.info("Deleted {} certificates", deletedRowCount); + } + + + @Transactional + public void updateFailedAndResetErrorCount(){ + log.info("Updating failed Certificates and resetting error count"); + int updatedRowCount = certificatePrintQueueRepository.updateFailedAndResetErrorCount(); + log.info("Updated {} failed certificates", updatedRowCount); + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/service/CertificatePrintingJob.java b/src/main/java/ch/admin/bag/covidcertificate/service/CertificatePrintingJob.java new file mode 100644 index 0000000..fcafc95 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/service/CertificatePrintingJob.java @@ -0,0 +1,84 @@ +package ch.admin.bag.covidcertificate.service; + +import ch.admin.bag.covidcertificate.config.SftpConfig; +import ch.admin.bag.covidcertificate.domain.CertificatePrintQueueItem; +import ch.admin.bag.covidcertificate.domain.CertificatePrintStatus; +import com.opencsv.exceptions.CsvDataTypeMismatchException; +import com.opencsv.exceptions.CsvRequiredFieldEmptyException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CertificatePrintingJob { + private final SftpConfig.PrintingServiceSftpGateway gateway; + private final CertificatePrintService certificatePrintService; + private final ZipService zipService; + private final FileService fileService; + + @Value("${cc-printing-service.temp-folder}") + private String tempFolder; + + @Value("${cc-printing-service.zip-size}") + private Integer zipSize; + + @Async + public void sendOverSftpAsync(){ + sendOverSftp(); + } + + public void sendOverSftp() { + log.info("Starting job to send certificates for printing"); + var createdBeforeTimestamp = LocalDateTime.now(); + Page certificatePrintQueues = certificatePrintService.getNotProcessedItems(createdBeforeTimestamp, zipSize); + while(!certificatePrintQueues.getContent().isEmpty()) + { + try { + var successfullySentCertificates = sendOverSftpPage(certificatePrintQueues); + log.info("Successfully sent {} certificates for printing", successfullySentCertificates.size()); + } catch (CsvRequiredFieldEmptyException| CsvDataTypeMismatchException| IOException| RuntimeException e) { + log.error("Failed to send certificates for printing", e); + certificatePrintService.increaseErrorCount(certificatePrintQueues.getContent()); + } + certificatePrintQueues = certificatePrintService.getNotProcessedItems(createdBeforeTimestamp, zipSize); + } + + log.info("End job to send certificates for printing"); + } + + @Transactional + public List sendOverSftpPage(Page certificatePrintQueues) throws CsvRequiredFieldEmptyException, CsvDataTypeMismatchException, IOException { + if(certificatePrintQueues.isEmpty()){ + return Collections.emptyList(); + } + log.info("Preparing {} certificates to send for printing", certificatePrintQueues.getSize()); + + Path rootPath = fileService.createCertificatesRootDirectory(tempFolder); + File zipFile = rootPath.getParent().resolve(rootPath.toFile().getName() + ".zip").toFile(); + try { + var successfullyCreatedCertificates = fileService.createPdfFiles(certificatePrintQueues, rootPath); + fileService.createMetaFile(successfullyCreatedCertificates, rootPath); + zipService.zipIt(rootPath, zipFile); + log.info("Sending {} for printing", zipFile.getName()); + gateway.sendToSftp(zipFile); + log.info("Successfully sent {} for printing", zipFile.getName()); + certificatePrintService.updateStatus(successfullyCreatedCertificates, CertificatePrintStatus.PROCESSED); + return successfullyCreatedCertificates; + }finally { + fileService.deleteTempData(rootPath, zipFile); + } + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/service/CleanupScheduler.java b/src/main/java/ch/admin/bag/covidcertificate/service/CleanupScheduler.java new file mode 100644 index 0000000..05846c5 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/service/CleanupScheduler.java @@ -0,0 +1,29 @@ +package ch.admin.bag.covidcertificate.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +@Slf4j +@ConditionalOnProperty(value = "CF_INSTANCE_INDEX", havingValue = "0") +public class CleanupScheduler { + private final CertificatePrintService certificatePrintService; + + @Value("${cc-printing-service.print-queue.cleanup-until-number-of-days}") + private Integer numberOfDaysInThePast; + + @Scheduled(cron = "${cc-printing-service.print-queue.cleanup-schedule}") + void deleteProcessedCertificatesModifiedUntilDate() { + log.info("Starting job to cleanup certificates processed."); + var untilTimestamp = LocalDateTime.now().minusDays(numberOfDaysInThePast); + certificatePrintService.deleteProcessedCertificatesModifiedUntilDate(untilTimestamp); + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/service/CsvWriterService.java b/src/main/java/ch/admin/bag/covidcertificate/service/CsvWriterService.java new file mode 100644 index 0000000..70c531c --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/service/CsvWriterService.java @@ -0,0 +1,51 @@ +package ch.admin.bag.covidcertificate.service; + +import ch.admin.bag.covidcertificate.domain.CertificatePrintMetadata; +import com.opencsv.bean.ColumnPositionMappingStrategy; +import com.opencsv.bean.StatefulBeanToCsv; +import com.opencsv.bean.StatefulBeanToCsvBuilder; +import com.opencsv.exceptions.CsvDataTypeMismatchException; +import com.opencsv.exceptions.CsvRequiredFieldEmptyException; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.util.List; + +@Component +public class CsvWriterService { + public void writeRowsToCsv(File file, List rows) + throws IOException, CsvRequiredFieldEmptyException, CsvDataTypeMismatchException { + FileWriter writer = new FileWriter(file); + var mappingStrategy = new ColumnPositionMappingStrategy(); + mappingStrategy.setType(CertificatePrintMetadata.class); + + var builder = new StatefulBeanToCsvBuilder(writer); + var beanWriter = builder + .withMappingStrategy(mappingStrategy) + .withApplyQuotesToAll(false) + .withSeparator('|') + .withLineEnd("|\r\n") + .build(); + + writeHeader(beanWriter); + beanWriter.write(rows); + writer.close(); + } + + private void writeHeader(StatefulBeanToCsv beanWriter) throws CsvRequiredFieldEmptyException, CsvDataTypeMismatchException { + beanWriter.write( + new CertificatePrintMetadata("UVCI", + "ADRESSZEILE_1", + "ADRESSZEILE_2", + "ADRESSZEILE_3", + "ADRESSZEILE_4", + "ADRESSE_PLZ", + "ADRESSE_ORT", + "ABSENDER", + "SPRACHE", + "PRIO", + "BEILAGE")); + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/service/FileService.java b/src/main/java/ch/admin/bag/covidcertificate/service/FileService.java new file mode 100644 index 0000000..e4ecad6 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/service/FileService.java @@ -0,0 +1,119 @@ +package ch.admin.bag.covidcertificate.service; + +import ch.admin.bag.covidcertificate.domain.CertificatePrintMetadataMapper; +import ch.admin.bag.covidcertificate.domain.CertificatePrintQueueItem; +import com.opencsv.exceptions.CsvDataTypeMismatchException; +import com.opencsv.exceptions.CsvRequiredFieldEmptyException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +import static ch.admin.bag.covidcertificate.domain.UvciUtils.mapFilenameFromUVCI; + +@Component +@RequiredArgsConstructor +@Slf4j +public class FileService { + private final CsvWriterService csvWriterService; + private final CertificatePrintMetadataMapper certificatePrintMetadataMapper; + + Path createCertificatesRootDirectory(String tempFolder) throws IOException { + Path rootPath = Path.of(tempFolder, "certificates_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmssSSS"))); + return createdDirectory(rootPath); + } + + private Path createdDirectory(Path rootPath) throws IOException { + try { + log.info("Creating folder {}", rootPath.getFileName()); + rootPath = Files.createDirectories(rootPath).toAbsolutePath(); + log.info("Created folder {}", rootPath.getFileName()); + } catch (IOException e) { + log.error("Failed to created folder {}", rootPath.getFileName(), e); + throw e; + } + return rootPath; + } + + List createPdfFiles(Page certificatePrintQueues, Path rootPath){ + log.info("Creating pdf files in temp folder : {}", rootPath.getFileName()); + List failedCertificates = new ArrayList<>(); + for (CertificatePrintQueueItem item : certificatePrintQueues) { + var filename = mapFilenameFromUVCI(item.getUvci()); + var file = rootPath.resolve(filename).toFile(); + try { + createPdf(item, file); + } catch (IOException e) { + log.error("Failed to create pdf file for uvci {}", filename, e); + failedCertificates.add(item); + } + } + log.info("{} pdf files were created", certificatePrintQueues.getSize()); + + return certificatePrintQueues.filter( + certificatePrintQueueItem -> !failedCertificates.contains(certificatePrintQueueItem) + ).toList(); + } + void createPdf(CertificatePrintQueueItem printCertificateRequestDto, File file) throws IOException { + try (OutputStream out = new FileOutputStream(file)) { + out.write(printCertificateRequestDto.getCertificatePdfData().getPdf()); + } + } + + void createMetaFile(List certificatePrintQueues, Path rootPath) throws CsvRequiredFieldEmptyException, CsvDataTypeMismatchException, IOException { + log.info("Creating metadata csv file"); + File metaFile = rootPath.resolve(rootPath.getFileName()+".csv").toFile(); + try { + csvWriterService.writeRowsToCsv(metaFile, certificatePrintMetadataMapper.mapAll(certificatePrintQueues)); + } catch (IOException| CsvRequiredFieldEmptyException |CsvDataTypeMismatchException| NullPointerException e) { + log.error("Failed to created meta file {}", metaFile.getName(), e); + throw e; + } + log.info("Successfully created {} pdf files", certificatePrintQueues.size()); + } + + + + void deleteTempData(Path directory, File zipFile) throws IOException { + log.info("Deleting folder {} and file {}", directory.getFileName(), zipFile.getName()); + try { + if(directory.toFile().exists()) { + deleteDirectory(directory); + } + } catch (IOException e) { + log.error("Failed to delete folder{}", directory.getFileName(), e); + throw e; + } + try { + if(zipFile.exists()) { + Files.delete(zipFile.toPath()); + } + } catch (IOException e) { + log.error("Failed to delete file {}", zipFile.getName(), e); + throw e; + } + log.info("Successfully deleted folder {} and file {}", directory.getFileName(), zipFile.getName()); + } + + private void deleteDirectory(Path directoryToBeDeleted) throws IOException { + File[] allContents = directoryToBeDeleted.toFile().listFiles(); + if (allContents != null) { + for (File file : allContents) { + deleteDirectory(file.toPath()); + } + } + Files.delete(directoryToBeDeleted); + } + +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/service/PrintQueueScheduler.java b/src/main/java/ch/admin/bag/covidcertificate/service/PrintQueueScheduler.java new file mode 100644 index 0000000..3dd02bd --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/service/PrintQueueScheduler.java @@ -0,0 +1,20 @@ +package ch.admin.bag.covidcertificate.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +@ConditionalOnProperty(value = "CF_INSTANCE_INDEX", havingValue = "0") +public class PrintQueueScheduler { + private final CertificatePrintingJob certificatePrintingJob; + + @Scheduled(cron = "${cc-printing-service.print-queue.schedule}") + void sendOverSftp() { + certificatePrintingJob.sendOverSftp(); + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/service/ZipService.java b/src/main/java/ch/admin/bag/covidcertificate/service/ZipService.java new file mode 100644 index 0000000..3cda3df --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/service/ZipService.java @@ -0,0 +1,48 @@ +package ch.admin.bag.covidcertificate.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Objects; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ZipService { + + public void zipIt(Path sourceRootPath, File zipFile) throws IOException { + log.info("Creating Zip file {}", zipFile.getName()); + byte[] buffer = new byte[1024]; + + try (FileOutputStream fos = new FileOutputStream(zipFile); + ZipOutputStream zos = new ZipOutputStream(fos)){ + + for (File file: Objects.requireNonNull(sourceRootPath.toFile().listFiles())) { + log.info("File Added : {}", file.getName()); + ZipEntry ze = new ZipEntry(file.getName()); + zos.putNextEntry(ze); + try (FileInputStream in = new FileInputStream(file)) { + int len; + while ((len = in .read(buffer)) > 0) { + zos.write(buffer, 0, len); + } + } + } + + zos.closeEntry(); + log.info("Folder successfully compressed"); + + } catch (IOException e) { + log.error("Failed to compress folder {}", zipFile.getName(), e); + throw e; + } + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/web/config/OpenApiConfig.java b/src/main/java/ch/admin/bag/covidcertificate/web/config/OpenApiConfig.java new file mode 100644 index 0000000..0db24cd --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/web/config/OpenApiConfig.java @@ -0,0 +1,24 @@ +package ch.admin.bag.covidcertificate.web.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .components(new Components()) + .info(new Info() + .title("HA EventCode Generation Service") + .description("Rest API for EventCode Generation Service.") + .version("0.0.1") + .license(new License().name("Apache 2.0")) + ); + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/web/controller/MaintenanceController.java b/src/main/java/ch/admin/bag/covidcertificate/web/controller/MaintenanceController.java new file mode 100644 index 0000000..5de360a --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/web/controller/MaintenanceController.java @@ -0,0 +1,39 @@ +package ch.admin.bag.covidcertificate.web.controller; + +import ch.admin.bag.covidcertificate.domain.CertificatePrintStatus; +import ch.admin.bag.covidcertificate.service.CertificatePrintService; +import ch.admin.bag.covidcertificate.service.CertificatePrintingJob; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; + +@RestController +@RequestMapping("/api/int/v1/") +@RequiredArgsConstructor +@Slf4j +public class MaintenanceController { + private final CertificatePrintService certificatePrintService; + private final CertificatePrintingJob certificatePrintingJob; + + @GetMapping("print") + public ResponseEntity print(@RequestParam(required = false) boolean retryFailed) { + log.info("Starting sending certificates with status {} for printing", CertificatePrintStatus.CREATED.name()); + if(retryFailed){ + certificatePrintService.updateFailedAndResetErrorCount(); + } + certificatePrintingJob.sendOverSftpAsync(); + return new ResponseEntity<>(HttpStatus.OK); + } + + @GetMapping("delete-processed/{modifiedAt}") + public ResponseEntity deleteProcessedBeforeDate(@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime modifiedAt) { + log.info("Deleting certificates with status {}", CertificatePrintStatus.PROCESSED.name()); + certificatePrintService.deleteProcessedCertificatesModifiedUntilDate(modifiedAt); + return new ResponseEntity<>(HttpStatus.OK); + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/web/controller/PrintQueueController.java b/src/main/java/ch/admin/bag/covidcertificate/web/controller/PrintQueueController.java new file mode 100644 index 0000000..1d8a606 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/web/controller/PrintQueueController.java @@ -0,0 +1,33 @@ +package ch.admin.bag.covidcertificate.web.controller; + +import ch.admin.bag.covidcertificate.api.CertificatePrintQueueItemMapper; +import ch.admin.bag.covidcertificate.api.CertificatePrintRequestDto; +import ch.admin.bag.covidcertificate.service.CertificatePrintService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/v1/print") +@RequiredArgsConstructor +@Slf4j +public class PrintQueueController { + private final CertificatePrintService certificatePrintService; + + @PostMapping + @PreAuthorize("hasRole('bag-cc-certificatecreator')") + public ResponseEntity print(@Valid @RequestBody CertificatePrintRequestDto certificatePrintRequestDto) { + log.info("Adding certificate with uvci {} to the print queue", certificatePrintRequestDto.getUvci()); + certificatePrintService.saveCertificateInPrintQueue(CertificatePrintQueueItemMapper.create(certificatePrintRequestDto)); + log.info("Successfully added Certificate {} for printing", certificatePrintRequestDto.getUvci()); + return new ResponseEntity<>(HttpStatus.CREATED); + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/web/monitoring/ActuatorConfig.java b/src/main/java/ch/admin/bag/covidcertificate/web/monitoring/ActuatorConfig.java new file mode 100644 index 0000000..f4c50f1 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/web/monitoring/ActuatorConfig.java @@ -0,0 +1,13 @@ +package ch.admin.bag.covidcertificate.web.monitoring; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; + +@Lazy +@Component +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ActuatorConfig { + +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/web/monitoring/ActuatorSecurity.java b/src/main/java/ch/admin/bag/covidcertificate/web/monitoring/ActuatorSecurity.java new file mode 100644 index 0000000..44545a6 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/web/monitoring/ActuatorSecurity.java @@ -0,0 +1,45 @@ +package ch.admin.bag.covidcertificate.web.monitoring; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.actuate.health.HealthEndpoint; +import org.springframework.boot.actuate.info.InfoEndpoint; +import org.springframework.boot.actuate.logging.LoggersEndpoint; +import org.springframework.boot.actuate.metrics.export.prometheus.PrometheusScrapeEndpoint; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; + +@Configuration +@Order(Ordered.HIGHEST_PRECEDENCE + 9) +public class ActuatorSecurity extends WebSecurityConfigurerAdapter { + + private static final String PROMETHEUS_ROLE = "PROMETHEUS"; + + @Value("${cc-printing-service.monitor.prometheus.user}") + private String user; + @Value("${cc-printing-service.monitor.prometheus.password}") + private String password; + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.requestMatcher(org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest.toAnyEndpoint()). + authorizeRequests(). + requestMatchers(org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest.to(HealthEndpoint.class)).permitAll(). + requestMatchers(org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest.to(InfoEndpoint.class)).permitAll(). + requestMatchers(org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest.to(LoggersEndpoint.class)).hasRole(PROMETHEUS_ROLE). + requestMatchers(org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest.to(PrometheusScrapeEndpoint.class)).hasRole(PROMETHEUS_ROLE). + anyRequest().denyAll(). + and(). + httpBasic(); + + http.csrf().ignoringAntMatchers("/actuator/loggers/**"); + } + + @Override + protected void configure(AuthenticationManagerBuilder auth) throws Exception { + auth.inMemoryAuthentication().withUser(user).password(password).roles(PROMETHEUS_ROLE); + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/web/monitoring/HealthMetricsConfig.java b/src/main/java/ch/admin/bag/covidcertificate/web/monitoring/HealthMetricsConfig.java new file mode 100644 index 0000000..8cee9f6 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/web/monitoring/HealthMetricsConfig.java @@ -0,0 +1,25 @@ +package ch.admin.bag.covidcertificate.web.monitoring; + + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer; +import org.springframework.boot.actuate.health.HealthEndpoint; +import org.springframework.boot.actuate.health.Status; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +@Configuration +class HealthMetricsConfig { + + @Bean + public MeterRegistryCustomizer prometheusHealthCheck(HealthEndpoint healthEndpoint) { + return registry -> registry.gauge("health", healthEndpoint, HealthMetricsConfig::healthToCode); + } + + private static int healthToCode(HealthEndpoint ep) { + Status status = ep.health().getStatus(); + return status.equals(Status.UP) ? 1 : 0; + } + +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/web/security/HttpResponseHeaderFilter.java b/src/main/java/ch/admin/bag/covidcertificate/web/security/HttpResponseHeaderFilter.java new file mode 100644 index 0000000..dc9449d --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/web/security/HttpResponseHeaderFilter.java @@ -0,0 +1,19 @@ +package ch.admin.bag.covidcertificate.web.security; + +import javax.servlet.*; +import javax.servlet.annotation.WebFilter; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@WebFilter(urlPatterns = {"/v1/verify/*", "/v1/eventcode/*", "/v1/events/*"}) +public class HttpResponseHeaderFilter implements Filter { + @Override + public void doFilter(ServletRequest request, ServletResponse response, + FilterChain chain) throws IOException, ServletException { + HttpServletResponse httpServletResponse = (HttpServletResponse) response; + httpServletResponse.setHeader("Content-Security-Policy", "default-src 'self'"); + httpServletResponse.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + httpServletResponse.setHeader("Feature-Policy", "microphone 'none'; payment 'none'; camera 'none'"); + chain.doFilter(request, response); + } +} diff --git a/src/main/java/ch/admin/bag/covidcertificate/web/security/RestInternalSecurityConfig.java b/src/main/java/ch/admin/bag/covidcertificate/web/security/RestInternalSecurityConfig.java new file mode 100644 index 0000000..49ce9a1 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/web/security/RestInternalSecurityConfig.java @@ -0,0 +1,49 @@ +package ch.admin.bag.covidcertificate.web.security; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; + +/** + * When including the jeap-spring-boot-security-starter dependency and providing the matching configuration properties + * all web endpoints of the application will be automatically protected by OAuth2 as a default. If in addition web endpoints + * with different protection (i.e. basic auth or no protection at all) must be provided at the same time by the application + * an additional WebSecurityConfigurerAdapter configuration (like the one below) needs to explicitly punch a hole into + * the jeap-spring-boot-security-starter OAuth2 protection with an appropriate HttpSecurity configuration. + * Note: jeap-spring-boot-monitoring-starter already does exactly that for the prometheus actuator endpoint. + */ +@Configuration +@Order(Ordered.HIGHEST_PRECEDENCE) +public class RestInternalSecurityConfig extends WebSecurityConfigurerAdapter { + + private static final String ADMIN_ROLE = "ADMIN"; + + @Value("${cc-printing-service.internal.maintenance.user}") + private String user; + @Value("${cc-printing-service.internal.maintenance.password}") + private String password; + + @Autowired + public void configure(AuthenticationManagerBuilder auth) throws Exception { + auth.inMemoryAuthentication() + .withUser(user) + .password(password) + .authorities(ADMIN_ROLE); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.requestMatcher(new AntPathRequestMatcher("/api/int/**")) + .authorizeRequests() + .antMatchers("/api/int/**") + .fullyAuthenticated() + .and() + .httpBasic(); + } +} \ No newline at end of file diff --git a/src/main/java/ch/admin/bag/covidcertificate/web/security/WebSecurityConfig.java b/src/main/java/ch/admin/bag/covidcertificate/web/security/WebSecurityConfig.java new file mode 100644 index 0000000..2eaa7d2 --- /dev/null +++ b/src/main/java/ch/admin/bag/covidcertificate/web/security/WebSecurityConfig.java @@ -0,0 +1,48 @@ +package ch.admin.bag.covidcertificate.web.security; + +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.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +/** + * When including the jeap-spring-boot-security-starter dependency and providing the matching configuration properties + * all web endpoints of the application will be automatically protected by OAuth2 as a default. If in addition web endpoints + * with different protection (i.e. basic auth or no protection at all) must be provided at the same time by the application + * an additional WebSecurityConfigurerAdapter configuration (like the one below) needs to explicitly punch a hole into + * the jeap-spring-boot-security-starter OAuth2 protection with an appropriate HttpSecurity configuration. + * Note: jeap-spring-boot-monitoring-starter already does exactly that for the prometheus actuator endpoint. + */ +@Configuration +public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + + @Value("${cc-printing-service.allowed-origin}") + private String allowedOrigin; + + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.requestMatchers(). + antMatchers("/actuator/**", "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**"). + and(). + authorizeRequests().anyRequest().permitAll(); + } + + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of(allowedOrigin)); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowedMethods(List.of("*")); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + +} diff --git a/src/main/resources/application-abn.yml b/src/main/resources/application-abn.yml new file mode 100644 index 0000000..de39d71 --- /dev/null +++ b/src/main/resources/application-abn.yml @@ -0,0 +1,26 @@ +jeap: + security: + oauth2: + resourceserver: + authorization-server: + issuer: "https://identity-a.bit.admin.ch/realms/BAG-CovidCertificate" + +cc-printing-service: + monitor: + prometheus: + user: "prometheus" + password: ${vcap.services.cc_prometheus.credentials.password} + allowed-origin: "https://www.covidcertificate-a.admin.ch" + internal: + maintenance: + user: ${vcap.services.cc_printing_user.credentials.user} + password: ${vcap.services.cc_printing_user.credentials.password} + + + +bbl: + sftp: + host: ${vcap.services.cc_bbl_sftp.credentials.host} + port: 22 + user: ${vcap.services.cc_bbl_sftp.credentials.user} + password: ${vcap.services.cc_bbl_sftp.credentials.password} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..18d56ac --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,29 @@ +jeap: + security: + oauth2: + resourceserver: + authorization-server: + issuer: "https://identity-r.bit.admin.ch/realms/BAG-CovidCertificate" + +cc-printing-service: + monitor: + prometheus: + user: "prometheus" + password: ${vcap.services.cc_prometheus.credentials.password} + + allowed-origin: "https://www.covidcertificate-d.admin.ch" + print-queue: + schedule: "0 0 * * * *" + internal: + maintenance: + user: ${vcap.services.cc_printing_user.credentials.user} + password: ${vcap.services.cc_printing_user.credentials.password} + + + +bbl: + sftp: + host: ${vcap.services.cc_bbl_sftp.credentials.host} + port: 22 + user: ${vcap.services.cc_bbl_sftp.credentials.user} + password: ${vcap.services.cc_bbl_sftp.credentials.password} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..f1db862 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,58 @@ +spring: + flyway: + locations: classpath:db/migration/common +jeap: + security: + oauth2: + resourceserver: + authorization-server: + issuer: "http://localhost:8180" + jwk-set-uri: "http://localhost:8180/.well-known/jwks.json" +cc-printing-service: + allowed-origin: "http://localhost:4201" + monitor: + prometheus: + user: "prometheus" + password: "{noop}secret" + service: + sleepLogInterval: 1 + print-queue: + schedule: "0 */5 * * * *" + cleanup-schedule: "0 */5 * * * *" + cleanup-until-number-of-days: 2 + temp-folder: "./temp/certificates" + zip-size: 1000 + internal: + maintenance: + user: "user1" + password: "{noop}user1Pass" + +## Uncomment the following to increase logging; then issue +## `mvn compile` to copy this configuration under target/ +# server: +# tomcat: +# basedir: /tmp +# accesslog: +# enabled: true +# directory: /dev +# prefix: stdout +# buffered: false +# suffix: +# file-date-format: +# +# logging: +# level: +# org.apache.tomcat: DEBUG +# org.apache.catalina: DEBUG +# org: +# apache: +# tomcat: DEBUG +# catalina: DEBUG + + +bbl: + sftp: + host: localhost + port: 2222 + user: foo + password: pass \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..efa0bb9 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,26 @@ +jeap: + security: + oauth2: + resourceserver: + authorization-server: + issuer: "https://identity.bit.admin.ch/realms/BAG-CovidCertificate" + +cc-printing-service: + monitor: + prometheus: + user: "prometheus" + password: ${vcap.services.cc_prometheus.credentials.password} + allowed-origin: "https://www.covidcertificate.admin.ch" + internal: + maintenance: + user: ${vcap.services.cc_printing_user.credentials.user} + password: ${vcap.services.cc_printing_user.credentials.password} + + + +bbl: + sftp: + host: ${vcap.services.cc_bbl_sftp.credentials.host} + port: 22 + user: ${vcap.services.cc_bbl_sftp.credentials.user} + password: ${vcap.services.cc_bbl_sftp.credentials.password} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..4a59d04 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,130 @@ +info: + build: + artifact: '@project.artifactId@' + description: '@project.description@' + name: '@project.name@' + version: '@project.version@' +logging: + level: + ch: + admin: + bit: + jeap: DEBUG + bag: DEBUG + io: + swagger: + models: + parameters: + AbstractSerializableParameter: ERROR + org: + hibernate: ERROR + springframework: + security: + authentication: + event: + LoggerListener: ERROR + oauth2: + server: + resource: + web: + BearerTokenAuthenticationFilter: INFO + web: + servlet: + resource: + ResourceHttpRequestHandler: INFO + filter: + CommonsRequestLoggingFilter: INFO + springfox: + documentation: + spring: + web: + readers: + operation: + CachingOperationNameGenerator: ERROR + pattern: + level: '[%X{correlationId}] %5p' + config: classpath:logback-spring.xml + file: + name: log.log +server: + port: 8124 + servlet: + context-path: / +spring: + application: + name: cc-printing-service + datasource: + type: com.zaxxer.hikari.HikariDataSource + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:3122/cc-printing + username: cc-printing + password: secret + hikari: + maximum-pool-size: 10 + pool-name: hikari-cp-${spring.application.name} + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQL10Dialect + show-sql: false + open-in-view: false + flyway: + enabled: true + clean-on-validation-error: false + locations: classpath:db/migration/common,classpath:db/migration/postgresql + jackson: + serialization: + write_dates_as_timestamps: false + messages: + basename: mail-messages,validation-messages + encoding: UTF-8 + fallback-to-system-locale: false + + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + session: + store-type: none + data: + rest: + base-path: /api + max-page-size: 100 + default-page-size: 20 + main: + banner-mode: off + +management: + endpoints: + web: + exposure: + include: 'info,health,prometheus,loggers' + endpoint: + health: + show-details: always + flyway: + enabled: true + +cc-printing-service: + rest: + connectTimeoutSeconds: 5 + readTimeoutSeconds: 5 + service: + callCountLimit: 1 + codeExpirationDelay: 1440 + deletionCron: "0 0 2 * * ?" + onsetSubtractionDays: 2 + requestTime: 500 + sleepLogInterval: 30000 + monitor: + prometheus: + secure: false + print-queue: + schedule: "0 0 23 * * *" + max-error-count: 3 + cleanup-schedule: "0 0 19 * * *" + cleanup-until-number-of-days: 14 + temp-folder: "./temp/certificates" + zip-size: 500 \ No newline at end of file diff --git a/src/main/resources/db/migration/common/V1_0_0__create-schema.sql b/src/main/resources/db/migration/common/V1_0_0__create-schema.sql new file mode 100644 index 0000000..ca3b55f --- /dev/null +++ b/src/main/resources/db/migration/common/V1_0_0__create-schema.sql @@ -0,0 +1,27 @@ +create table certificate_pdf_data +( + id uuid not null + constraint pdf_certificate_data_pkey + primary key, + pdf bytea not null +); + +create table certificate_print_queue_item +( + id uuid not null + constraint certificate_print_queue_item_pkey + primary key, + uvci varchar(39) not null unique, + status varchar(39) not null, + address_line1 varchar(39), + address_line2 varchar(39), + address_line3 varchar(39), + zip_code integer, + city varchar(39), + language varchar(39), + certificate_pdf_data_id uuid not null REFERENCES certificate_pdf_data (id), + created_at timestamp not null default now(), + modified_at timestamp not null default now() +); + +CREATE INDEX certificate_print_queue_item_status_idx ON certificate_print_queue_item (status); \ No newline at end of file diff --git a/src/main/resources/db/migration/common/V1_0_1__create-schema.sql b/src/main/resources/db/migration/common/V1_0_1__create-schema.sql new file mode 100644 index 0000000..9848e43 --- /dev/null +++ b/src/main/resources/db/migration/common/V1_0_1__create-schema.sql @@ -0,0 +1,5 @@ +ALTER TABLE certificate_print_queue_item + DROP COLUMN address_line3; + +ALTER TABLE certificate_print_queue_item + ADD COLUMN canton_code_sender varchar(2); \ No newline at end of file diff --git a/src/main/resources/db/migration/common/V1_0_2__create-schema.sql b/src/main/resources/db/migration/common/V1_0_2__create-schema.sql new file mode 100644 index 0000000..fe5b0ad --- /dev/null +++ b/src/main/resources/db/migration/common/V1_0_2__create-schema.sql @@ -0,0 +1,14 @@ +ALTER TABLE certificate_print_queue_item + ALTER COLUMN status SET DATA TYPE varchar(20); + +ALTER TABLE certificate_print_queue_item + ALTER COLUMN address_line1 SET DATA TYPE varchar(200); + +ALTER TABLE certificate_print_queue_item + ALTER COLUMN address_line2 SET DATA TYPE varchar(200); + +ALTER TABLE certificate_print_queue_item + ALTER COLUMN city SET DATA TYPE varchar(200); + +ALTER TABLE certificate_print_queue_item + ALTER COLUMN language SET DATA TYPE varchar(2); \ No newline at end of file diff --git a/src/main/resources/db/migration/common/V1_0_3__create-schema.sql b/src/main/resources/db/migration/common/V1_0_3__create-schema.sql new file mode 100644 index 0000000..e688bfc --- /dev/null +++ b/src/main/resources/db/migration/common/V1_0_3__create-schema.sql @@ -0,0 +1,36 @@ +-- Change direction of dependency. Move dependency to the dependent instead of the parent table. +-- Certificate_pdf_data should reference certificate_print_queue_item and not the other way around. +-- This allows for delete cascade. +ALTER TABLE certificate_pdf_data + ADD COLUMN certificate_pdf_print_queue_item_id uuid UNIQUE + REFERENCES certificate_print_queue_item (id) + ON DELETE CASCADE; + +DELETE FROM certificate_print_queue_item +WHERE status = 'PROCCESSED'; + +DELETE FROM certificate_pdf_data pdf_data +WHERE pdf_data.id in ( + SELECT pdf_data.id + FROM certificate_pdf_data pdf_data + LEFT OUTER JOIN certificate_print_queue_item item + ON pdf_data.id = item.certificate_pdf_data_id + WHERE item.id is null +); + +UPDATE certificate_pdf_data pdf_data +SET certificate_pdf_print_queue_item_id = ( + SELECT item.id + FROM ( + SELECT * + FROM certificate_print_queue_item + ) item + WHERE item.certificate_pdf_data_id = pdf_data.id +); + +ALTER TABLE certificate_pdf_data + ALTER COLUMN certificate_pdf_print_queue_item_id SET NOT NULL; + +ALTER TABLE certificate_print_queue_item + DROP COLUMN certificate_pdf_data_id; + diff --git a/src/main/resources/db/migration/common/V1_0_4__add_column_error_count.sql b/src/main/resources/db/migration/common/V1_0_4__add_column_error_count.sql new file mode 100644 index 0000000..494a50b --- /dev/null +++ b/src/main/resources/db/migration/common/V1_0_4__add_column_error_count.sql @@ -0,0 +1,2 @@ +ALTER TABLE certificate_print_queue_item + ADD COLUMN error_count integer default 0; \ No newline at end of file diff --git a/src/main/resources/db/migration/common/V1_0_5__add_indexes.sql b/src/main/resources/db/migration/common/V1_0_5__add_indexes.sql new file mode 100644 index 0000000..5adc9f5 --- /dev/null +++ b/src/main/resources/db/migration/common/V1_0_5__add_indexes.sql @@ -0,0 +1,3 @@ +CREATE INDEX certificate_print_queue_item_status_created_at_idx ON certificate_print_queue_item (status,created_at); + +CREATE INDEX certificate_print_queue_item_status_modified_at_idx ON certificate_print_queue_item (status,modified_at); \ No newline at end of file diff --git a/src/main/resources/db/migration/postgresql/afterMigrate.sql b/src/main/resources/db/migration/postgresql/afterMigrate.sql new file mode 100644 index 0000000..3d38d32 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/afterMigrate.sql @@ -0,0 +1,11 @@ +create or replace procedure reassign_objects_ownership() +LANGUAGE 'plpgsql' +as $BODY$ +BEGIN + execute format('reassign owned by %s to %s_role_full', user, current_database()); +END +$BODY$; + +call reassign_objects_ownership(); + +drop procedure reassign_objects_ownership(); diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..ee53187 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,85 @@ + + + + + + + + + UTF-8 + %d %highlight(%-5level) [${app},%X{X-B3-TraceId:-}] %cyan(%logger{35}) - %msg %marker%n + + + + + + + + + logger + 20 + + + + + + + + + exception-hash + + + exception + + 40 + 4096 + 20 + true + sun\.reflect\..*\.invoke.* + + + + + + + + + + + log.log + + log.log.%i + 1 + 3 + + + 2MB + + + UTF-8 + %d %highlight(%-5level) [${app},%X{X-B3-TraceId:-}] %cyan(%logger{35}) - %msg %marker%n + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/senderAddress.csv b/src/main/resources/senderAddress.csv new file mode 100644 index 0000000..066cc87 --- /dev/null +++ b/src/main/resources/senderAddress.csv @@ -0,0 +1,27 @@ +CantonCode;Address +AG;5001 Aarau, DGS, Postfach 2254 +AI;9050 Appenzell, GSD, Postfach 162 +AR;9100 Herisau, ZS Zertifikate, Schützenstrasse 1 +BE;3000 Bern 8, GSI, Postfach 552 +BL;4410 Liestal, VGD BL, Postfach 639 +BS;4001 Basel, GD BS, Postfach 2048 +FR;1752 Villars-sur-Glâne, SMC, Rte de Villars 101 +GE;1207 Genève, DGS, Service du Médecin Cantonal, rue Adrien-Lachenal 8 +GL;8750 Glarus, DFG, Postfach 768 +GR;7001 Chur GA Kt. GR, Postfach 999 +JU;2800 Delémont, SSA, case postale 386 +LU;6002 Luzern, DIGE, Postfach 3439 +NE;2000 Neuchâtel, SSP, Secrétariat de Vaccination, Rue des Draizes 5 +NW;6371 Stans, GSD GA, Postfach 1243 +OW;6060 Sarnen, GA, Fachstelle Covid, St. Antonistrasse 4 +SG;9001 St. Gallen, GD, Postfach 1640 +SH;8200 Schaffhausen. GA, Mühlentalstrasse 105 +SO;4500 Solothurn GA, Ambassendorenhof / Riedholzplatz 3 +SZ;6431 Schwyz, AGS, Postfach 2161 +TG;8510 Frauenfeld, AfG, Promenadenstrasse 16 +TI;6802 Rivera CIPC, Via Ravello 2 +UR;6460 Altdorf, AfG, Klausenstrasse 4 +VD;1007 Lausanne, OMC CT, Chemin des Plaines 17 +VS;1951 Sion, SSP, case postale 478 +ZG;6340 Baar, Impfzentrum, Langgasse 40 +ZH;8330 Pfäffikon ZH, JDMT, Speerstrasse 15 diff --git a/src/test/java/ch/admin/bag/covidcertificate/config/WithAuthentication.java b/src/test/java/ch/admin/bag/covidcertificate/config/WithAuthentication.java new file mode 100644 index 0000000..395ddc6 --- /dev/null +++ b/src/test/java/ch/admin/bag/covidcertificate/config/WithAuthentication.java @@ -0,0 +1,17 @@ +package ch.admin.bag.covidcertificate.config; + +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.*; + +/** + * Make a test method run with the WithAuthenticationExtension Junit 5 extension and specify the authentication factory method + * to be used by the extension to populate the SpringSecurityContext the test method is executed with. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +@Documented +@ExtendWith(WithAuthenticationExtension.class) +public @interface WithAuthentication { + String value(); +} diff --git a/src/test/java/ch/admin/bag/covidcertificate/config/WithAuthenticationExtension.java b/src/test/java/ch/admin/bag/covidcertificate/config/WithAuthenticationExtension.java new file mode 100644 index 0000000..32d8301 --- /dev/null +++ b/src/test/java/ch/admin/bag/covidcertificate/config/WithAuthenticationExtension.java @@ -0,0 +1,78 @@ +package ch.admin.bag.covidcertificate.config; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.junit.platform.commons.util.AnnotationUtils; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Optional; + +/** + * Junit 5 extension that populates the SpringSecurityContext with a certain Authentication instance before executing the test method. + * The authentication used to populate the context is created by executing the factory method specified by the WithAuthentication annotation. + * If the WithAuthentication annotation is missing on the test method the security context is left unchanged. + */ +public class WithAuthenticationExtension implements InvocationInterceptor { + + @Override + public void interceptTestMethod(InvocationInterceptor.Invocation invocation, ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + Optional withAuthenticationAnnotation = getWithAuthenticationAnnotation(extensionContext); + if (!withAuthenticationAnnotation.isPresent()) { + invocation.proceed(); + } + else { + Authentication authenticationToSet = getAuthentication(extensionContext, withAuthenticationAnnotation.get().value()); + SecurityContext securityContext = getSecurityContext(); + Authentication previousAuthentication = securityContext.getAuthentication(); + securityContext.setAuthentication(authenticationToSet); + try { + invocation.proceed(); + } finally { + securityContext.setAuthentication(previousAuthentication); + } + } + } + + private SecurityContext getSecurityContext() { + SecurityContext securityContext = SecurityContextHolder.getContext(); + if (securityContext == null) { + securityContext = SecurityContextHolder.createEmptyContext(); + SecurityContextHolder.setContext(securityContext); + } + return securityContext; + } + + private Optional getWithAuthenticationAnnotation(ExtensionContext extensionContext) { + return AnnotationUtils.findAnnotation(extensionContext.getElement(), WithAuthentication.class); + } + + private Authentication getAuthentication(ExtensionContext extensionContext, String authenticationFactoryMethodName) { + Object authenticationFactoryMethodResult = executeAuthenticationFactoryMethod(extensionContext.getRequiredTestInstance(), authenticationFactoryMethodName); + if ( (authenticationFactoryMethodResult != null) && !(authenticationFactoryMethodResult instanceof Authentication) ) { + throw new IllegalArgumentException("Authentication factory method with name '" + authenticationFactoryMethodName + "' did not produce an object of type Authentication."); + } + else { + return (Authentication) authenticationFactoryMethodResult; + } + } + + @SuppressWarnings("java:S3011") // We explicitly want to support non public authentication factory methods for the test setup + private Object executeAuthenticationFactoryMethod(Object testInstance, String authenticationFactoryMethodName) { + try { + Method authenticationFactoryMethod = testInstance.getClass().getDeclaredMethod(authenticationFactoryMethodName); + // allow the execution of authentication factory methods with lesser visibility than public + authenticationFactoryMethod.setAccessible(true); + return authenticationFactoryMethod.invoke(testInstance); + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException("No authentication factory method with name '" + authenticationFactoryMethodName + "' found in test class.", e); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException("Unable to execute authentication factory method with name '" + authenticationFactoryMethodName + "'.", e); + } + } + +} diff --git a/src/test/java/ch/admin/bag/covidcertificate/domain/CertificatePrintMetadataMapperTest.java b/src/test/java/ch/admin/bag/covidcertificate/domain/CertificatePrintMetadataMapperTest.java new file mode 100644 index 0000000..d56f34d --- /dev/null +++ b/src/test/java/ch/admin/bag/covidcertificate/domain/CertificatePrintMetadataMapperTest.java @@ -0,0 +1,121 @@ +package ch.admin.bag.covidcertificate.domain; + +import com.flextrade.jfixture.JFixture; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +@SpringBootTest(classes = CertificatePrintMetadataMapper.class ) +class CertificatePrintMetadataMapperTest { + @Autowired + private CertificatePrintMetadataMapper certificatePrintMetadataMapper; + + private final JFixture fixture = new JFixture(); + + @Nested + class MapAll{ + @Test + void shouldMapAllEntries() { + var input = fixture.collections().createCollection(CertificatePrintQueueItem.class); + var actual = certificatePrintMetadataMapper.mapAll(input); + assertEquals(input.size(), actual.size()); + } + + @Test + void shouldMapUvci() { + var input = fixture.create(CertificatePrintQueueItem.class); + var actual = certificatePrintMetadataMapper.mapAll(Collections.singleton(input)); + + assertEquals(input.getUvci(), actual.get(0).getUvci()); + } + + @Test + void shouldMapAddressLine1() { + var input = fixture.create(CertificatePrintQueueItem.class); + var actual = certificatePrintMetadataMapper.mapAll(Collections.singleton(input)); + + assertEquals(input.getAddressLine1(), actual.get(0).getAddressLine1()); + } + + @Test + void shouldMapAddressLine2() { + var input = fixture.create(CertificatePrintQueueItem.class); + var actual = certificatePrintMetadataMapper.mapAll(Collections.singleton(input)); + + assertEquals(input.getAddressLine2(), actual.get(0).getAddressLine2()); + } + + @Test + void shouldMapAddressLine3ToNull() { + var input = fixture.create(CertificatePrintQueueItem.class); + var actual = certificatePrintMetadataMapper.mapAll(Collections.singleton(input)); + + assertNull(actual.get(0).getAddressLine3()); + } + + @Test + void shouldMapAddressLine4ToNull() { + var input = fixture.create(CertificatePrintQueueItem.class); + var actual = certificatePrintMetadataMapper.mapAll(Collections.singleton(input)); + + assertNull(actual.get(0).getAddressLine4()); + } + + @Test + void shouldMapZipCode() { + var input = fixture.create(CertificatePrintQueueItem.class); + var actual = certificatePrintMetadataMapper.mapAll(Collections.singleton(input)); + + assertEquals(String.valueOf(input.getZipCode()), actual.get(0).getZipCode()); + } + + @Test + void shouldMapCity() { + var input = fixture.create(CertificatePrintQueueItem.class); + var actual = certificatePrintMetadataMapper.mapAll(Collections.singleton(input)); + + assertEquals(input.getCity(), actual.get(0).getCity()); + } + + @Test + void shouldMapSender() { + var input = fixture.create(CertificatePrintQueueItem.class); + ReflectionTestUtils.setField(input, "cantonCodeSender", "TE"); + var actual = certificatePrintMetadataMapper.mapAll(Collections.singleton(input)); + + assertEquals("1234 test", actual.get(0).getSender()); + } + + @Test + void shouldMapLanguage() { + var input = fixture.create(CertificatePrintQueueItem.class); + var actual = certificatePrintMetadataMapper.mapAll(Collections.singleton(input)); + + assertEquals(input.getLanguage(), actual.get(0).getLanguage()); + } + + @Test + void shouldMapPrio() { + var input = fixture.create(CertificatePrintQueueItem.class); + var actual = certificatePrintMetadataMapper.mapAll(Collections.singleton(input)); + + assertEquals("1", actual.get(0).getPrio()); + } + + @Test + void shouldMapFilename() { + var input = fixture.create(CertificatePrintQueueItem.class); + var actual = certificatePrintMetadataMapper.mapAll(Collections.singleton(input)); + + assertEquals(UvciUtils.mapFilenameFromUVCI(input.getUvci()), actual.get(0).getFilename()); + } + } + +} \ No newline at end of file diff --git a/src/test/java/ch/admin/bag/covidcertificate/domain/CertificatePrintQueueRepositoryTest.java b/src/test/java/ch/admin/bag/covidcertificate/domain/CertificatePrintQueueRepositoryTest.java new file mode 100644 index 0000000..cbc927c --- /dev/null +++ b/src/test/java/ch/admin/bag/covidcertificate/domain/CertificatePrintQueueRepositoryTest.java @@ -0,0 +1,102 @@ +package ch.admin.bag.covidcertificate.domain; + +import com.flextrade.jfixture.JFixture; +import org.junit.Ignore; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@DataJpaTest(properties = { + "spring.jpa.hibernate.ddl-auto=create", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.url=jdbc:h2:mem:testDb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE", + "spring.datasource.username=sa", + "spring.datasource.password=sa", + "spring.flyway.clean-on-validation-error=true" +}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) +@ActiveProfiles({"local"}) +@ExtendWith(SpringExtension.class) +@Ignore +class CertificatePrintQueueRepositoryTest { + @Autowired + private CertificatePrintQueueRepository repository; + @PersistenceContext + private EntityManager entityManager; + + private final JFixture fixture = new JFixture(); + +// @Nested +// class GetNotProcessedItems { +// @Test +// @Transactional +// void shouldReturnEmptyPage_whenNoUnprocessedItemsInTheDB() { +// persist(CertificatePrintStatus.PROCESSED); +// Pageable p = PageRequest.of(0,20); +// Page result = repository.getNotProcessedItems(LocalDateTime.now().plusYears(10), p); +// +// assertThat(result).isEmpty(); +// } +// +// @Test +// @Transactional +// void shouldReturnRequestedPage_whenUnprocessedItemsExistInTheDB() { +// int pageSize = 20; +// persist(CertificatePrintStatus.CREATED, 30); +// Pageable page1 = PageRequest.of(0,pageSize); +// Pageable page2 = PageRequest.of(1,pageSize); +// Page resultPage1 = repository.getNotProcessedItems(LocalDateTime.now().plusYears(10), page1); +// Page resultPage2 = repository.getNotProcessedItems(LocalDateTime.now().plusYears(10), page2); +// +// assertEquals(pageSize,resultPage1.toList().size()); +// assertEquals(10,resultPage2.toList().size()); +// } +// +// @Test +// @Transactional +// void shouldLoadItemCorrectly_whenUnprocessedItemsExistInTheDB() { +// CertificatePrintQueueItem expected = persist(CertificatePrintStatus.CREATED); +// Pageable p = PageRequest.of(0,20); +// Page result = repository.getNotProcessedItems(LocalDateTime.now().plusYears(10), p); +// +// assertEquals(1, result.toList().size()); +// assertEquals(expected, result.getContent().get(0)); +// } +// } + + + private List persist(CertificatePrintStatus status, int numberOfItems) { + List items = new ArrayList<>(); + for(int i=0; i < numberOfItems; i++){ + items.add(persist(status)); + } + return items; + } + + private CertificatePrintQueueItem persist(CertificatePrintStatus status) { + CertificatePrintQueueItem certificatePrintQueueItem = fixture.create(CertificatePrintQueueItem.class); + certificatePrintQueueItem.setStatus(status.name()); + ReflectionTestUtils.setField(certificatePrintQueueItem, "createdAt", LocalDateTime.now()); + entityManager.persist(certificatePrintQueueItem); + return certificatePrintQueueItem; + } +} diff --git a/src/test/java/ch/admin/bag/covidcertificate/service/CertificatePrintServiceTest.java b/src/test/java/ch/admin/bag/covidcertificate/service/CertificatePrintServiceTest.java new file mode 100644 index 0000000..a5719c7 --- /dev/null +++ b/src/test/java/ch/admin/bag/covidcertificate/service/CertificatePrintServiceTest.java @@ -0,0 +1,207 @@ +package ch.admin.bag.covidcertificate.service; + +import ch.admin.bag.covidcertificate.domain.CertificatePrintQueueItem; +import ch.admin.bag.covidcertificate.domain.CertificatePrintQueueRepository; +import ch.admin.bag.covidcertificate.domain.CertificatePrintStatus; +import com.flextrade.jfixture.JFixture; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.test.util.ReflectionTestUtils; + +import java.sql.Array; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CertificatePrintServiceTest { + @InjectMocks + private CertificatePrintService certificatePrintService; + + @Mock + private CertificatePrintQueueRepository certificatePrintQueueRepository; + + private int maxErrorCount = 3; + + private final JFixture fixture = new JFixture(); + + @BeforeEach + void init(){ + ReflectionTestUtils.setField(certificatePrintService, "maxErrorCount", maxErrorCount); + } + + @Nested + class GetNotProcessedItem { + @Test + void shouldLoadNotProcessItems_withCorrectPageNumber() { + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + + certificatePrintService.getNotProcessedItems(fixture.create(LocalDateTime.class), fixture.create(Integer.class)); + + verify(certificatePrintQueueRepository).getNotProcessedItems(any(), pageableCaptor.capture()); + Assertions.assertEquals(0, pageableCaptor.getValue().getPageNumber()); + } + + @Test + void shouldLoadNotProcessItems_withCorrectPageSize() { + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + int size = fixture.create(Integer.class); + + certificatePrintService.getNotProcessedItems(fixture.create(LocalDateTime.class), size); + + verify(certificatePrintQueueRepository).getNotProcessedItems(any(), pageableCaptor.capture()); + Assertions.assertEquals(size, pageableCaptor.getValue().getPageSize()); + } + + @Test + void shouldLoadNotProcessItems_withTimestamp() { + var createdBefore = fixture.create(LocalDateTime.class); + + certificatePrintService.getNotProcessedItems(createdBefore, fixture.create(Integer.class)); + + verify(certificatePrintQueueRepository).getNotProcessedItems(eq(createdBefore), any()); + } + + @Test + void shouldLoadNotProcessItems_withCorrectSorting() { + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + + certificatePrintService.getNotProcessedItems(fixture.create(LocalDateTime.class), fixture.create(Integer.class)); + + verify(certificatePrintQueueRepository).getNotProcessedItems(any(), pageableCaptor.capture()); + Assertions.assertEquals(Sort.by(Sort.Direction.ASC, "createdAt"), pageableCaptor.getValue().getSort()); + } + + @Test + void shouldReturnLoadedPage() { + Page expected = new PageImpl<>(new ArrayList<>(fixture.collections().createCollection(CertificatePrintQueueItem.class))); + when(certificatePrintQueueRepository.getNotProcessedItems(any(), any())).thenReturn(expected); + + var actual = certificatePrintService.getNotProcessedItems(fixture.create(LocalDateTime.class), fixture.create(Integer.class)); + + Assertions.assertEquals(expected, actual); + } + } + + @Nested + class SaveCertificateInPrintQueue { + @Test + void shouldSaveCertificate() { + var certificatePrintQueueItem = fixture.create(CertificatePrintQueueItem.class); + certificatePrintService.saveCertificateInPrintQueue(certificatePrintQueueItem); + verify(certificatePrintQueueRepository).saveAndFlush(certificatePrintQueueItem); + } + } + + @Nested + class UpdateStatus { + @Test + void shouldSaveAllCertificatesWithCorrectStatus() { + var certificates = fixture.collections().createCollection(CertificatePrintQueueItem.class); + certificates.forEach(item -> item.setStatus(CertificatePrintStatus.CREATED.name())); + var expectedStatus = CertificatePrintStatus.PROCESSED; + + certificatePrintService.updateStatus(certificates, expectedStatus); + + verify(certificatePrintQueueRepository).saveAll(certificates); + assertTrue(certificates.stream().allMatch(item -> item.getStatus().equals(expectedStatus.name()))); + } + + @Test + void shouldSaveAllCertificatesWithCurrentDateAsModifiedAt() { + var certificates = fixture.collections().createCollection(CertificatePrintQueueItem.class); + var expected = fixture.create(LocalDateTime.class); + + try (MockedStatic localDateTimeMock = Mockito.mockStatic(LocalDateTime.class, Mockito.CALLS_REAL_METHODS)) { + localDateTimeMock.when(LocalDateTime::now).thenReturn(expected); + certificatePrintService.updateStatus(certificates, CertificatePrintStatus.PROCESSED); + + verify(certificatePrintQueueRepository).saveAll(certificates); + assertTrue(certificates.stream().allMatch(item -> item.getModifiedAt().equals(expected))); + } + } + } + + @Nested + class IncreaseErrorCount { + @Test + void shouldSaveAllCertificatesWithIncreasedErrorCount() { + var errorCount1 = fixture.create(Integer.class); + var errorCount2 = fixture.create(Integer.class); + var certificatesErrorCount1 = createCertificatePrintQueueItems(errorCount1); + var certificatesErrorCount2 = createCertificatePrintQueueItems(errorCount2); + var certificates = new ArrayList<>(certificatesErrorCount1); + certificates.addAll(certificatesErrorCount2); + + certificatePrintService.increaseErrorCount(certificates); + + verify(certificatePrintQueueRepository).saveAll(certificates); + assertTrue(certificatesErrorCount1.stream().allMatch(item -> item.getErrorCount().equals(errorCount1 + 1))); + assertTrue(certificatesErrorCount2.stream().allMatch(item -> item.getErrorCount().equals(errorCount2 + 1))); + } + + @Test + void shouldSaveAllCertificatesWithCorrectStatus() { + var certificatesZeroErrorCount = createCertificatePrintQueueItems(0); + var certificatesMaxErrorCount = createCertificatePrintQueueItems(maxErrorCount-1); + var certificates = new ArrayList<>(certificatesZeroErrorCount); + certificates.addAll(certificatesMaxErrorCount); + + certificatePrintService.increaseErrorCount(certificates); + + verify(certificatePrintQueueRepository).saveAll(certificates); + assertTrue(certificatesZeroErrorCount.stream().allMatch(item -> item.getStatus().equals(CertificatePrintStatus.CREATED.name()))); + assertTrue(certificatesMaxErrorCount.stream().allMatch(item -> item.getStatus().equals(CertificatePrintStatus.ERROR.name()))); + } + + @Test + void shouldSaveAllCertificatesWithCurrentDateAsModifiedAt() { + var certificates = fixture.collections().createCollection(CertificatePrintQueueItem.class); + var expected = fixture.create(LocalDateTime.class); + + try (MockedStatic localDateTimeMock = Mockito.mockStatic(LocalDateTime.class, Mockito.CALLS_REAL_METHODS)) { + localDateTimeMock.when(LocalDateTime::now).thenReturn(expected); + certificatePrintService.increaseErrorCount(certificates); + + verify(certificatePrintQueueRepository).saveAll(certificates); + assertTrue(certificates.stream().allMatch(item -> item.getModifiedAt().equals(expected))); + } + } + + private List createCertificatePrintQueueItems(int errorCount){ + var certificatePrintQueueItems = new ArrayList<>(fixture.collections().createCollection(CertificatePrintQueueItem.class)); + certificatePrintQueueItems.forEach(item -> { + item.setStatus(CertificatePrintStatus.CREATED.name()); + item.setErrorCount(errorCount); + }); + return certificatePrintQueueItems; + } + } + + @Nested + class DeleteProcessedCertificatesModifiedUntilDate { + @Test + void shouldDeleteAllCertificatesProcessedBeforeGivenDateTime() { + var expected = fixture.create(LocalDateTime.class); + + certificatePrintService.deleteProcessedCertificatesModifiedUntilDate(expected); + + verify(certificatePrintQueueRepository).deleteItemsProcessedBeforeTimestamp(expected); + } + } +} \ No newline at end of file diff --git a/src/test/java/ch/admin/bag/covidcertificate/service/CertificatePrintingJobTest.java b/src/test/java/ch/admin/bag/covidcertificate/service/CertificatePrintingJobTest.java new file mode 100644 index 0000000..2c6744a --- /dev/null +++ b/src/test/java/ch/admin/bag/covidcertificate/service/CertificatePrintingJobTest.java @@ -0,0 +1,392 @@ +package ch.admin.bag.covidcertificate.service; + +import ch.admin.bag.covidcertificate.config.SftpConfig; +import ch.admin.bag.covidcertificate.domain.CertificatePrintQueueItem; +import ch.admin.bag.covidcertificate.domain.CertificatePrintStatus; +import com.flextrade.jfixture.JFixture; +import com.opencsv.exceptions.CsvDataTypeMismatchException; +import com.opencsv.exceptions.CsvRequiredFieldEmptyException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CertificatePrintingJobTest { + @InjectMocks + private CertificatePrintingJob certificatePrintingJob; + + @Mock + private SftpConfig.PrintingServiceSftpGateway gateway; + @Mock + private CertificatePrintService certificatePrintService; + @Mock + private ZipService zipService; + @Mock + private FileService fileService; + + private final JFixture fixture = new JFixture(); + + private static final String TEMP_FOLDER = "tmp/test/certificates"; + private static final Integer zipSize = 100; + + @BeforeEach + public void init() throws IOException { + ReflectionTestUtils.setField(certificatePrintingJob, "tempFolder", TEMP_FOLDER); + ReflectionTestUtils.setField(certificatePrintingJob, "zipSize", zipSize); + lenient().when(fileService.createCertificatesRootDirectory(any())).thenReturn(Path.of(TEMP_FOLDER, "certificates_"+ LocalDateTime.now())); + } + + @Nested + class SendOverSftp { + @Test + void shouldLoadAllPages() { + var numberOfPages = 10; + List> pages = createPages(numberOfPages); + var stubbing = when(certificatePrintService.getNotProcessedItems(any(), any())); + for(Page page: pages){ + stubbing = stubbing.thenReturn(page); + } + + certificatePrintingJob.sendOverSftp(); + + verify(certificatePrintService, times(numberOfPages+1)).getNotProcessedItems(any(), any()); + } + + @Test + void shouldLoadEachPage_withTheConfiguredSize() { + Page emptyPage = new PageImpl<>(Collections.emptyList()); + when(certificatePrintService.getNotProcessedItems(any(), any())) + .thenReturn(createPage()) + .thenReturn(emptyPage); + + certificatePrintingJob.sendOverSftp(); + + verify(certificatePrintService, times(2)).getNotProcessedItems(any(), eq(zipSize)); + } + + + @Test + void shouldLoadEachPage_withTheCorrectTimestamp() { + Page emptyPage = new PageImpl<>(Collections.emptyList()); + when(certificatePrintService.getNotProcessedItems(any(), any())) + .thenReturn(createPage()) + .thenReturn(emptyPage); + var expected = fixture.create(LocalDateTime.class); + + try (MockedStatic localDateTimeMock = Mockito.mockStatic(LocalDateTime.class, Mockito.CALLS_REAL_METHODS)) { + localDateTimeMock.when(LocalDateTime::now).thenReturn(expected); + certificatePrintingJob.sendOverSftp(); + + verify(certificatePrintService, times(2)).getNotProcessedItems(eq(expected), any()); + } + } + + @Test + void shouldProcessAllPages() throws CsvRequiredFieldEmptyException, CsvDataTypeMismatchException, IOException { + var certificatePrintingJobSpy = spy(certificatePrintingJob); + Page page1 = new PageImpl<>(new ArrayList<>(fixture.collections().createCollection(CertificatePrintQueueItem.class))); + Page page2 = new PageImpl<>(new ArrayList<>(fixture.collections().createCollection(CertificatePrintQueueItem.class))); + Page emptyPage = new PageImpl<>(Collections.emptyList()); + when(certificatePrintService.getNotProcessedItems(any(), any())) + .thenReturn(page1) + .thenReturn(page2) + .thenReturn(emptyPage); + + certificatePrintingJobSpy.sendOverSftp(); + + verify(certificatePrintingJobSpy).sendOverSftpPage(page1); + verify(certificatePrintingJobSpy).sendOverSftpPage(page2); + verify(certificatePrintingJobSpy, times(2)).sendOverSftpPage(any()); + } + + @ParameterizedTest + @ValueSource(classes = {IOException.class, CsvRequiredFieldEmptyException.class, CsvDataTypeMismatchException.class, RuntimeException.class}) + void shouldIncreaseErrorCount_ifSendingPageForPrintingFails(Class exceptionClass) throws IOException, CsvRequiredFieldEmptyException, CsvDataTypeMismatchException { + var certificatePrintingJobSpy = spy(certificatePrintingJob); + Page page = new PageImpl<>(new ArrayList<>(fixture.collections().createCollection(CertificatePrintQueueItem.class))); + Page emptyPage = new PageImpl<>(Collections.emptyList()); + when(certificatePrintService.getNotProcessedItems(any(), any())) + .thenReturn(page) + .thenReturn(emptyPage); + var expected = fixture.create(exceptionClass); + doThrow(expected).when(certificatePrintingJobSpy).sendOverSftpPage(any()); + + certificatePrintingJobSpy.sendOverSftp(); + + verify(certificatePrintService).increaseErrorCount(page.getContent()); + } + } + + @Nested + class SendOverSftpPage { + @Nested + class SuccessfulExecution { + @Test + void shouldCreatedTempDirectory() throws IOException, CsvRequiredFieldEmptyException, CsvDataTypeMismatchException { + certificatePrintingJob.sendOverSftpPage(createPage()); + verify(fileService, times(1)).createCertificatesRootDirectory(TEMP_FOLDER); + } + + @Test + void shouldCreatePdfFiles() throws IOException, CsvRequiredFieldEmptyException, CsvDataTypeMismatchException { + var page = createPage(); + var rootPath = Path.of(fixture.create(String.class), fixture.create(String.class)); + when(fileService.createCertificatesRootDirectory(any())).thenReturn(rootPath); + + certificatePrintingJob.sendOverSftpPage(page); + + verify(fileService, times(1)).createPdfFiles(page, rootPath); + } + + @Test + void shouldCreateMetaFile() throws IOException, CsvRequiredFieldEmptyException, CsvDataTypeMismatchException { + var successfullyCertificates = createPage().getContent(); + var rootPath = Path.of(fixture.create(String.class), fixture.create(String.class)); + when(fileService.createCertificatesRootDirectory(any())).thenReturn(rootPath); + when(fileService.createPdfFiles(any(), any())).thenReturn(successfullyCertificates); + + certificatePrintingJob.sendOverSftpPage(createPage()); + + verify(fileService, times(1)).createMetaFile(successfullyCertificates, rootPath); + } + + @Test + void shouldZipFolder() throws IOException, CsvRequiredFieldEmptyException, CsvDataTypeMismatchException { + var rootPath = Path.of(fixture.create(String.class), fixture.create(String.class)); + File zipFile = rootPath.getParent().resolve(rootPath.toFile().getName() + ".zip").toFile(); + when(fileService.createCertificatesRootDirectory(any())).thenReturn(rootPath); + + certificatePrintingJob.sendOverSftpPage(createPage()); + + verify(zipService, times(1)).zipIt(rootPath, zipFile); + } + + @Test + void shouldSendZipFileOverSftp() throws IOException, CsvRequiredFieldEmptyException, CsvDataTypeMismatchException { + var rootPath = Path.of(fixture.create(String.class), fixture.create(String.class)); + File zipFile = rootPath.getParent().resolve(rootPath.toFile().getName() + ".zip").toFile(); + when(fileService.createCertificatesRootDirectory(any())).thenReturn(rootPath); + + certificatePrintingJob.sendOverSftpPage(createPage()); + + verify(gateway, times(1)).sendToSftp(zipFile); + } + + @Test + void shouldUpdateStatusOfSuccessfullyProcessedCertificates() throws IOException, CsvRequiredFieldEmptyException, CsvDataTypeMismatchException { + var successfullyCertificates = createPage().getContent(); + when(fileService.createPdfFiles(any(), any())).thenReturn(successfullyCertificates); + + certificatePrintingJob.sendOverSftpPage(createPage()); + + verify(certificatePrintService, times(1)).updateStatus(successfullyCertificates, CertificatePrintStatus.PROCESSED); + } + + + @Test + void shouldDeleteTempFolder() throws IOException, CsvRequiredFieldEmptyException, CsvDataTypeMismatchException { + var rootPath = Path.of(fixture.create(String.class), fixture.create(String.class)); + File zipFile = rootPath.getParent().resolve(rootPath.toFile().getName() + ".zip").toFile(); + when(fileService.createCertificatesRootDirectory(any())).thenReturn(rootPath); + + certificatePrintingJob.sendOverSftpPage(createPage()); + + verify(fileService, times(1)).deleteTempData(rootPath, zipFile); + } + } + + @Nested + class UnSuccessfulExecution { + @Test + void shouldTrowException_ifCreateTempDirectoryThrowsException() throws IOException { + var expected = fixture.create(IOException.class); + when(fileService.createCertificatesRootDirectory(any())).thenThrow(expected); + + var actual = assertThrows(IOException.class, + () -> certificatePrintingJob.sendOverSftpPage(createPage()) + ); + + assertEquals(expected, actual); + } + + @Test + void shouldNotUpdateCertificates_ifCreateTempDirectoryThrowsException() throws IOException { + when(fileService.createCertificatesRootDirectory(any())).thenThrow(IOException.class); + + assertThrows(IOException.class, + () -> certificatePrintingJob.sendOverSftpPage(createPage()) + ); + + verifyNoInteractions(certificatePrintService); + } + + @Test + void shouldCreateMetaFile() throws IOException, CsvRequiredFieldEmptyException, CsvDataTypeMismatchException { + var successfullyCertificates = createPage().getContent(); + var rootPath = Path.of(fixture.create(String.class), fixture.create(String.class)); + when(fileService.createCertificatesRootDirectory(any())).thenReturn(rootPath); + when(fileService.createPdfFiles(any(), any())).thenReturn(successfullyCertificates); + + certificatePrintingJob.sendOverSftpPage(createPage()); + + verify(fileService, times(1)).createMetaFile(successfullyCertificates, rootPath); + } + + @ParameterizedTest + @ValueSource(classes = {IOException.class, CsvRequiredFieldEmptyException.class, CsvDataTypeMismatchException.class, RuntimeException.class}) + void shouldTrowException_ifCreateMetaFileThrowsException(Class exceptionClass) throws IOException, CsvRequiredFieldEmptyException, CsvDataTypeMismatchException { + var expected = fixture.create(exceptionClass); + doThrow(expected).when(fileService).createMetaFile(any(), any()); + + var actual = assertThrows(exceptionClass, + () -> certificatePrintingJob.sendOverSftpPage(createPage()) + ); + + assertEquals(expected, actual); + } + + @ParameterizedTest + @ValueSource(classes = {IOException.class, CsvRequiredFieldEmptyException.class, CsvDataTypeMismatchException.class, RuntimeException.class}) + void shouldNotUpdateCertificates_ifCreateMetaFileDirectoryThrowsException(Class exceptionClass) throws IOException, CsvRequiredFieldEmptyException, CsvDataTypeMismatchException { + doThrow(exceptionClass).when(fileService).createMetaFile(any(), any()); + + assertThrows(exceptionClass, + () -> certificatePrintingJob.sendOverSftpPage(createPage()) + ); + + verifyNoInteractions(certificatePrintService); + } + + @ParameterizedTest + @ValueSource(classes = {IOException.class, CsvRequiredFieldEmptyException.class, CsvDataTypeMismatchException.class, RuntimeException.class}) + void shouldDeleteTempFiles_ifCreateMetaFileDirectoryThrowsException(Class exceptionClass) throws IOException, CsvRequiredFieldEmptyException, CsvDataTypeMismatchException { + var rootPath = Path.of(fixture.create(String.class), fixture.create(String.class)); + File zipFile = rootPath.getParent().resolve(rootPath.toFile().getName() + ".zip").toFile(); + when(fileService.createCertificatesRootDirectory(any())).thenReturn(rootPath); + doThrow(exceptionClass).when(fileService).createMetaFile(any(), any()); + + assertThrows(exceptionClass, + () -> certificatePrintingJob.sendOverSftpPage(createPage()) + ); + + verify(fileService, times(1)).deleteTempData(rootPath, zipFile); + } + + @Test + void shouldTrowException_ifCreateZipFileThrowsException() throws IOException { + var expected = fixture.create(IOException.class); + doThrow(expected).when(zipService).zipIt(any(), any()); + + var actual = assertThrows(IOException.class, + () -> certificatePrintingJob.sendOverSftpPage(createPage()) + ); + + assertEquals(expected, actual); + } + + @Test + void shouldNotUpdateCertificates_ifCreateZipFileThrowsException() throws IOException { + doThrow(IOException.class).when(zipService).zipIt(any(), any()); + + assertThrows(IOException.class, + () -> certificatePrintingJob.sendOverSftpPage(createPage()) + ); + + verifyNoInteractions(certificatePrintService); + } + + @Test + void shouldDeleteTempFiles_ifCreateZipFileThrowsException() throws IOException { + var rootPath = Path.of(fixture.create(String.class), fixture.create(String.class)); + File zipFile = rootPath.getParent().resolve(rootPath.toFile().getName() + ".zip").toFile(); + when(fileService.createCertificatesRootDirectory(any())).thenReturn(rootPath); + doThrow(IOException.class).when(zipService).zipIt(any(), any()); + + assertThrows(IOException.class, + () -> certificatePrintingJob.sendOverSftpPage(createPage()) + ); + + verify(fileService, times(1)).deleteTempData(rootPath, zipFile); + } + + @Test + void shouldTrowException_ifSendZipFileOverSftpThrowsException() { + var page = createPage(); + var expected = fixture.create(RuntimeException.class); + doThrow(expected).when(gateway).sendToSftp(any()); + + var actual = assertThrows(RuntimeException.class, + () -> certificatePrintingJob.sendOverSftpPage(page) + ); + + assertEquals(expected, actual); + } + + @Test + void shouldNotUpdateCertificates_ifSendZipFileOverSftpThrowsException() { + var page = createPage(); + doThrow(RuntimeException.class).when(gateway).sendToSftp(any()); + + assertThrows(RuntimeException.class, + () -> certificatePrintingJob.sendOverSftpPage(page) + ); + + verifyNoInteractions(certificatePrintService); + } + + @Test + void shouldDeleteTempFiles_ifSendZipFileOverSftpThrowsException() throws IOException { + var page = createPage(); + var rootPath = Path.of(fixture.create(String.class), fixture.create(String.class)); + File zipFile = rootPath.getParent().resolve(rootPath.toFile().getName() + ".zip").toFile(); + when(fileService.createCertificatesRootDirectory(any())).thenReturn(rootPath); + doThrow(RuntimeException.class).when(gateway).sendToSftp(any()); + + assertThrows(RuntimeException.class, + () -> certificatePrintingJob.sendOverSftpPage(page) + ); + + verify(fileService, times(1)).deleteTempData(rootPath, zipFile); + } + } + } + + private List> createPages(int numberOfNonEmptyPages){ + List> pages = new ArrayList<>(); + for(int i = 0; i < numberOfNonEmptyPages; i++){ + pages.add(createPage()); + } + Page emptyPage = new PageImpl<>(Collections.emptyList()); + pages.add(emptyPage); + return pages; + } + + private Page createPage(){ + return new PageImpl<>(new ArrayList<>(fixture.collections().createCollection(CertificatePrintQueueItem.class))); + } + +} \ No newline at end of file diff --git a/src/test/java/ch/admin/bag/covidcertificate/service/CleanupSchedulerTest.java b/src/test/java/ch/admin/bag/covidcertificate/service/CleanupSchedulerTest.java new file mode 100644 index 0000000..9155a13 --- /dev/null +++ b/src/test/java/ch/admin/bag/covidcertificate/service/CleanupSchedulerTest.java @@ -0,0 +1,53 @@ +package ch.admin.bag.covidcertificate.service; + +import com.flextrade.jfixture.JFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.time.LocalDateTime; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + + +@ExtendWith(MockitoExtension.class) +class CleanupSchedulerTest { + @InjectMocks + private CleanupScheduler cleanupScheduler; + + @Mock + private CertificatePrintService certificatePrintService; + + private final JFixture fixture = new JFixture(); + + private static final Integer NUMBER_OF_DAYS_IN_THE_PAST = 10; + + @BeforeEach + public void init() throws IOException { + ReflectionTestUtils.setField(cleanupScheduler, "numberOfDaysInThePast", NUMBER_OF_DAYS_IN_THE_PAST); + } + + @Nested + class DeleteProcessedCertificatesModifiedUntilDate { + @Test + void shouldDeleteAllCertificatesProcessedBeforeConfiguredNumberOfDays() { + var now = fixture.create(LocalDateTime.class); + var expected = now.minusDays(NUMBER_OF_DAYS_IN_THE_PAST); + + try (MockedStatic localDateTimeMock = Mockito.mockStatic(LocalDateTime.class, Mockito.CALLS_REAL_METHODS)) { + localDateTimeMock.when(LocalDateTime::now).thenReturn(now); + cleanupScheduler.deleteProcessedCertificatesModifiedUntilDate(); + verify(certificatePrintService, times(1)).deleteProcessedCertificatesModifiedUntilDate(expected); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/ch/admin/bag/covidcertificate/service/PrintQueueSchedulerTest.java b/src/test/java/ch/admin/bag/covidcertificate/service/PrintQueueSchedulerTest.java new file mode 100644 index 0000000..a2b5f76 --- /dev/null +++ b/src/test/java/ch/admin/bag/covidcertificate/service/PrintQueueSchedulerTest.java @@ -0,0 +1,26 @@ +package ch.admin.bag.covidcertificate.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PrintQueueSchedulerTest { + @InjectMocks + private PrintQueueScheduler printQueueScheduler; + + @Mock + private CertificatePrintingJob certificatePrintingJob; + + @Test + void shouldSendCertificatesOverSftp(){ + printQueueScheduler.sendOverSftp(); + verify(certificatePrintingJob).sendOverSftp(); + } + + +} \ No newline at end of file diff --git a/src/test/java/ch/admin/bag/covidcertificate/testutil/JeapAuthenticationTestTokenBuilder.java b/src/test/java/ch/admin/bag/covidcertificate/testutil/JeapAuthenticationTestTokenBuilder.java new file mode 100644 index 0000000..bd8f43c --- /dev/null +++ b/src/test/java/ch/admin/bag/covidcertificate/testutil/JeapAuthenticationTestTokenBuilder.java @@ -0,0 +1,117 @@ +package ch.admin.bag.covidcertificate.testutil; + +import ch.admin.bag.covidcertificate.config.security.authentication.JeapAuthenticationContext; +import ch.admin.bag.covidcertificate.config.security.authentication.JeapAuthenticationToken; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.util.*; + +// @jEAP team: This builder does not follow the same conventions as the Lombok builders usually used by jEAP. +// -> when breaking changes are introduced, adapt this builder to the Lombok style. See also JwsBuilder. +/** + * This class can simplify the construction of a JeapAuthenticationToken instance for the most common use cases in unit and integration tests. + * This class implements a builder pattern. You can start building with an empty JWT (create()) and add claims and roles to it. Or you can start + * with a given JWT (createWithJwt()) and then add roles to it. + */ +public class JeapAuthenticationTestTokenBuilder { + + private Jwt jwt; + private Map claims; + private Set userRoles; + private Map> businessPartnerRoles; + + private JeapAuthenticationTestTokenBuilder(Jwt jwt) { + this.userRoles = new HashSet<>(); + this.businessPartnerRoles = new HashMap<>(); + this.claims = new HashMap<>(); + this.jwt = jwt; + } + + public static JeapAuthenticationTestTokenBuilder create() { + return new JeapAuthenticationTestTokenBuilder(null); + } + + public static JeapAuthenticationTestTokenBuilder createWithJwt(Jwt jwt) { + return new JeapAuthenticationTestTokenBuilder(jwt); + } + + public JeapAuthenticationTestTokenBuilder withContext(JeapAuthenticationContext context) { + return withClaim(JeapAuthenticationContext.getContextJwtClaimName(), context); + } + + public JeapAuthenticationTestTokenBuilder withName(String name) { + return withClaim("name", name); + } + + public JeapAuthenticationTestTokenBuilder withGivenName(String givenName) { + return withClaim("given_name", givenName); + } + + public JeapAuthenticationTestTokenBuilder withFamilyName(String familyName) { + return withClaim("family_name", familyName); + } + + public JeapAuthenticationTestTokenBuilder withLocale(String locale) { + return withClaim("locale", locale); + } + + public JeapAuthenticationTestTokenBuilder withSubject(String subject) { + return withClaim("sub", subject); + } + + public JeapAuthenticationTestTokenBuilder withClaim(String claimName, Object claimValue) { + checkNoTokenProvided(); + claims.put(claimName, claimValue); + return this; + } + + public JeapAuthenticationTestTokenBuilder withUserRoles(String... roles) { + userRoles.addAll(setOf(roles)); + return this; + } + + public JeapAuthenticationTestTokenBuilder withBusinessPartnerRoles(String businessPartner, String... roles) { + Set currentRoles = businessPartnerRoles.get(businessPartner); + if (currentRoles == null) { + currentRoles = new HashSet<>(); + businessPartnerRoles.put(businessPartner, currentRoles); + } + currentRoles.addAll(setOf(roles)); + return this; + } + + public JeapAuthenticationToken build() { + if (jwt != null) { + return new JeapAuthenticationToken(jwt, userRoles); + } + else { + Jwt.Builder jwtBuilder = createDefaultJwt(); + claims.entrySet().stream(). + forEach(entry -> jwtBuilder.claim(entry.getKey(), entry.getValue())); + return new JeapAuthenticationToken(jwtBuilder.build(), userRoles); + } + } + + private static Jwt.Builder createDefaultJwt() { + return Jwt.withTokenValue("dummy token value") + // at least one header needed + .header("alg", "none") + // at least one claim needed + .claim(JeapAuthenticationContext.getContextJwtClaimName(), JeapAuthenticationContext.USER.name()); + } + + private void checkNoTokenProvided() { + if (jwt != null) { + throw new IllegalStateException("Token has been set explicitly, unable to add additional token claims."); + } + } + + private Set setOf(E... elements) { + Set set = new HashSet<>(); + if (elements != null) { + set.addAll(Arrays.asList(elements)); + } + return set; + } + +} diff --git a/src/test/java/ch/admin/bag/covidcertificate/testutil/JwtTestUtil.java b/src/test/java/ch/admin/bag/covidcertificate/testutil/JwtTestUtil.java new file mode 100644 index 0000000..b377f4f --- /dev/null +++ b/src/test/java/ch/admin/bag/covidcertificate/testutil/JwtTestUtil.java @@ -0,0 +1,61 @@ +package ch.admin.bag.covidcertificate.testutil; + +import io.jsonwebtoken.Header; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; + +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.KeySpec; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; + +public class JwtTestUtil { + private static final String JWT_CONTEXT = "USER"; + private static final String FIRST_NAME = "Henriette"; + private static final String LAST_NAME = "Muster"; + private static final String PREFERRED_USERNAME = "12345"; + private static final String LOCALE_DE = "DE"; + private static final String CLIENT_ID = "ha-ui"; + private static final String CRYPTO_ALGORITHM = "RSA"; + private static final String ISSUER = "http://localhost:8180"; + + public static String getJwtTestToken(String privateKey, LocalDateTime expiration, String userRole) throws Exception { + KeySpec keySpec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey)); + KeyFactory kf = KeyFactory.getInstance(CRYPTO_ALGORITHM); + PrivateKey privateKeyToSignWith = kf.generatePrivate(keySpec); + Map claims = new LinkedHashMap<>(); + claims.put("user_name", "user"); + claims.put("ctx", JWT_CONTEXT); + claims.put("iss", ISSUER); + claims.put("preferred_username", PREFERRED_USERNAME); + claims.put("given_name", FIRST_NAME); + claims.put("locale", LOCALE_DE); + claims.put("client_id", CLIENT_ID); + claims.put("bproles", new HashMap<>()); + claims.put("userroles", Collections.singletonList(userRole)); + claims.put("scope", Arrays.asList("email", "openid", "profile")); + claims.put("name", FIRST_NAME + " " + LAST_NAME); + claims.put("exp", convertToDateViaInstant(expiration)); + claims.put("family_name", LAST_NAME); + claims.put("jti", UUID.randomUUID().toString()); + + return Jwts.builder() + .setId(UUID.randomUUID().toString()) + .setIssuer(ISSUER) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setSubject(UUID.randomUUID().toString()) + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setClaims(claims) + .signWith(privateKeyToSignWith, SignatureAlgorithm.RS256) + .compact(); + } + + private static Date convertToDateViaInstant(LocalDateTime dateToConvert) { + return java.util.Date + .from(dateToConvert.atZone(ZoneId.systemDefault()) + .toInstant()); + } +} diff --git a/src/test/java/ch/admin/bag/covidcertificate/testutil/KeyPairTestUtil.java b/src/test/java/ch/admin/bag/covidcertificate/testutil/KeyPairTestUtil.java new file mode 100644 index 0000000..fac433a --- /dev/null +++ b/src/test/java/ch/admin/bag/covidcertificate/testutil/KeyPairTestUtil.java @@ -0,0 +1,54 @@ +package ch.admin.bag.covidcertificate.testutil; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import org.json.JSONObject; + +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +public class KeyPairTestUtil { + + private static final String CRYPTO_ALGORITHM = "RSA"; + private static final int KEY_SIZE = 2048; + private static final String KEY_ID = "test-id"; + + private KeyPair keyPair; + + public KeyPairTestUtil() { + try { + this.keyPair = getKeyPair(); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } + } + + public String getPrivateKey() { + return Base64.getEncoder().encodeToString(this.keyPair.getPrivate().getEncoded()); + } + + public String getJwks() throws NoSuchAlgorithmException, InvalidKeySpecException { + KeyFactory keyFactory = KeyFactory.getInstance(CRYPTO_ALGORITHM); + X509EncodedKeySpec spec = new X509EncodedKeySpec(this.keyPair.getPublic().getEncoded()); + RSAPublicKey publicKeyObj = (RSAPublicKey) keyFactory.generatePublic(spec); + RSAKey.Builder builder = new RSAKey.Builder(publicKeyObj) + .keyUse(KeyUse.SIGNATURE) + .algorithm(JWSAlgorithm.RS256) + .keyID(KEY_ID); + return new JSONObject(new JWKSet(builder.build()).toJSONObject()).toString(); + } + + private KeyPair getKeyPair() throws NoSuchAlgorithmException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(CRYPTO_ALGORITHM); + keyPairGenerator.initialize(KEY_SIZE); + return keyPairGenerator.generateKeyPair(); + } +} diff --git a/src/test/java/ch/admin/bag/covidcertificate/testutil/LoggerTestUtil.java b/src/test/java/ch/admin/bag/covidcertificate/testutil/LoggerTestUtil.java new file mode 100644 index 0000000..64716cd --- /dev/null +++ b/src/test/java/ch/admin/bag/covidcertificate/testutil/LoggerTestUtil.java @@ -0,0 +1,19 @@ +package ch.admin.bag.covidcertificate.testutil; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.slf4j.LoggerFactory; + +public class LoggerTestUtil { + public static ListAppender getListAppenderForClass(Class clazz) { + Logger logger = (Logger) LoggerFactory.getLogger(clazz); + + ListAppender loggingEventListAppender = new ListAppender<>(); + loggingEventListAppender.start(); + + logger.addAppender(loggingEventListAppender); + + return loggingEventListAppender; + } +} diff --git a/src/test/java/ch/admin/bag/covidcertificate/web/controller/PrintQueueControllerSecurityTest.java b/src/test/java/ch/admin/bag/covidcertificate/web/controller/PrintQueueControllerSecurityTest.java new file mode 100644 index 0000000..caaff7f --- /dev/null +++ b/src/test/java/ch/admin/bag/covidcertificate/web/controller/PrintQueueControllerSecurityTest.java @@ -0,0 +1,127 @@ +package ch.admin.bag.covidcertificate.web.controller; + +import ch.admin.bag.covidcertificate.api.CertificatePrintRequestDto; +import ch.admin.bag.covidcertificate.config.security.OAuth2SecuredWebConfiguration; +import ch.admin.bag.covidcertificate.domain.CertificatePrintQueueItem; +import ch.admin.bag.covidcertificate.service.CertificatePrintService; +import ch.admin.bag.covidcertificate.testutil.JwtTestUtil; +import ch.admin.bag.covidcertificate.testutil.KeyPairTestUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.flextrade.jfixture.JFixture; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import org.junit.jupiter.api.*; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultMatcher; + +import java.time.LocalDateTime; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +@WebMvcTest(value = {PrintQueueController.class, OAuth2SecuredWebConfiguration.class}, + properties = "jeap.security.oauth2.resourceserver.authorization-server.jwk-set-uri=http://localhost:8182/.well-known/jwks.json") +// Avoid port 8180, see below +@ActiveProfiles("local") +class PrintQueueControllerSecurityTest { + @MockBean + private CertificatePrintService certificatePrintService; + @Autowired + private MockMvc mockMvc; + + private final ObjectMapper mapper = Jackson2ObjectMapperBuilder.json().modules(new JavaTimeModule()).build(); + private static final JFixture fixture = new JFixture(); + + private static final String URL = "/api/v1/print"; + private static final String VALID_USER_ROLE = "bag-cc-certificatecreator"; + private static final String INVALID_USER_ROLE = "invalid-role"; + // Avoid port 8180, which is likely used by the local KeyCloak: + private static final int MOCK_SERVER_PORT = 8182; + + + private static final KeyPairTestUtil KEY_PAIR_TEST_UTIL = new KeyPairTestUtil(); + private static final String PRIVATE_KEY = KEY_PAIR_TEST_UTIL.getPrivateKey(); + private static final LocalDateTime EXPIRED_IN_FUTURE = LocalDateTime.now().plusDays(1); + private static final LocalDateTime EXPIRED_IN_PAST = LocalDateTime.now().minusDays(1); + private static final WireMockServer wireMockServer = new WireMockServer(options().port(MOCK_SERVER_PORT)); + + @BeforeAll + private static void setup() throws Exception { + wireMockServer.start(); + wireMockServer.stubFor(WireMock.get(urlPathEqualTo("/.well-known/jwks.json")).willReturn(aResponse() + .withStatus(HttpStatus.OK.value()) + .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .withBody(KEY_PAIR_TEST_UTIL.getJwks()))); + } + + @BeforeEach + void setupMocks() { + lenient().doNothing().when(certificatePrintService).saveCertificateInPrintQueue(any(CertificatePrintQueueItem.class)); + } + + @AfterAll + static void teardown() { + wireMockServer.stop(); + } + + @Nested + class PrintCertificate { + @Test + void returnsOKIfAuthorizationTokenValid() throws Exception { + callGetValueSetsWithToken(EXPIRED_IN_FUTURE, VALID_USER_ROLE, HttpStatus.CREATED); + Mockito.verify(certificatePrintService, times(1)).saveCertificateInPrintQueue(any()); + } + + @Test + void returnsForbiddenIfAuthorizationTokenWithInvalidUserRole() throws Exception { + callGetValueSetsWithToken(EXPIRED_IN_FUTURE, INVALID_USER_ROLE, HttpStatus.FORBIDDEN); + Mockito.verify(certificatePrintService, times(0)).saveCertificateInPrintQueue(any()); + } + + @Test + void returnsUnauthorizedIfAuthorizationTokenExpired() throws Exception { + callGetValueSetsWithToken(EXPIRED_IN_PAST, VALID_USER_ROLE, HttpStatus.UNAUTHORIZED); + Mockito.verify(certificatePrintService, times(0)).saveCertificateInPrintQueue(any()); + } + } + + private void callGetValueSetsWithToken(LocalDateTime tokenExpiration, String userRole, HttpStatus status) throws Exception { + var printRequestDto = fixture.create(CertificatePrintRequestDto.class); + String token = JwtTestUtil.getJwtTestToken(PRIVATE_KEY, tokenExpiration, userRole); + mockMvc.perform(post(URL) + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .header("Authorization", "Bearer " + token) + .content(mapper.writeValueAsString(printRequestDto))) + .andExpect(getResultMatcher(status)); + } + + private ResultMatcher getResultMatcher(HttpStatus status) { + switch(status) { + case CREATED: + return status().isCreated(); + case FORBIDDEN: + return status().isForbidden(); + case UNAUTHORIZED: + return status().isUnauthorized(); + default: + throw new IllegalArgumentException("HttpStatus not found!"); + } + } +} diff --git a/src/test/java/ch/admin/bag/covidcertificate/web/controller/PrintQueueControllerTest.java b/src/test/java/ch/admin/bag/covidcertificate/web/controller/PrintQueueControllerTest.java new file mode 100644 index 0000000..1eef318 --- /dev/null +++ b/src/test/java/ch/admin/bag/covidcertificate/web/controller/PrintQueueControllerTest.java @@ -0,0 +1,108 @@ +package ch.admin.bag.covidcertificate.web.controller; + +import ch.admin.bag.covidcertificate.api.CertificatePrintQueueItemMapper; +import ch.admin.bag.covidcertificate.api.CertificatePrintRequestDto; +import ch.admin.bag.covidcertificate.domain.CertificatePrintQueueItem; +import ch.admin.bag.covidcertificate.service.CertificatePrintService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.flextrade.jfixture.JFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.server.handler.ResponseStatusExceptionHandler; + +import java.util.stream.Stream; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; + +@ExtendWith(MockitoExtension.class) +class PrintQueueControllerTest { + @InjectMocks + private PrintQueueController controller; + @Mock + private CertificatePrintService certificatePrintService; + + private MockMvc mockMvc; + + private final ObjectMapper mapper = Jackson2ObjectMapperBuilder.json().modules(new JavaTimeModule()).build(); + + private static final String URL = "/api/v1/print"; + + private static final JFixture fixture = new JFixture(); + + @BeforeEach + void setupMocks() { + this.mockMvc = standaloneSetup(controller, new ResponseStatusExceptionHandler()).build(); + lenient().doNothing().when(certificatePrintService).saveCertificateInPrintQueue(any(CertificatePrintQueueItem.class)); + } + + @Nested + class PrintCertificate { + @Test + void savesCertificatePrintRequest() throws Exception { + var printRequestDto = fixture.create(CertificatePrintRequestDto.class); + var certificatePrintQueueItem = fixture.create(CertificatePrintQueueItem.class); + + try (MockedStatic certificatePrintQueueItemMapperMock = Mockito.mockStatic(CertificatePrintQueueItemMapper.class)) { + certificatePrintQueueItemMapperMock.when((MockedStatic.Verification) CertificatePrintQueueItemMapper.create(printRequestDto)).thenReturn(certificatePrintQueueItem); + + mockMvc.perform(post(URL) + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .header("Authorization", fixture.create(String.class)) + .content(mapper.writeValueAsString(printRequestDto))) + .andExpect(status().isCreated()); + + verify(certificatePrintService).saveCertificateInPrintQueue(certificatePrintQueueItem); + } + } + + @Test + void returnsCreatedStatus() throws Exception { + var printRequestDto = fixture.create(CertificatePrintRequestDto.class); + + mockMvc.perform(post(URL) + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .header("Authorization", fixture.create(String.class)) + .content(mapper.writeValueAsString(printRequestDto))) + .andExpect(status().isCreated()); + } + + @ParameterizedTest + @MethodSource("ch.admin.bag.covidcertificate.web.controller.PrintQueueControllerTest#invalidCertificatePrintRequestDtos") + void validatesInputAndReturnsBadRequest_ifInputInvalid(CertificatePrintRequestDto printRequestDto) throws Exception { + mockMvc.perform(post(URL) + .accept(MediaType.APPLICATION_JSON_VALUE) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .header("Authorization", fixture.create(String.class)) + .content(mapper.writeValueAsString(printRequestDto))) + .andExpect(status().isBadRequest()); + } + } + + private static Stream invalidCertificatePrintRequestDtos() { + var printRequestDto1 = fixture.create(CertificatePrintRequestDto.class); + ReflectionTestUtils.setField(printRequestDto1, "pdfCertificate", null); + var printRequestDto2 = fixture.create(CertificatePrintRequestDto.class); + ReflectionTestUtils.setField(printRequestDto2, "uvci", null); + return Stream.of(printRequestDto1, printRequestDto2); + } +} diff --git a/src/test/resources/senderAddress.csv b/src/test/resources/senderAddress.csv new file mode 100644 index 0000000..4957c90 --- /dev/null +++ b/src/test/resources/senderAddress.csv @@ -0,0 +1,11 @@ +CantonCode;Address +TE;1234 test +AG;5001 Aarau, DGS, Postfach 2254 +AI;9050 Appenzell, GSD, Postfach 162 +AR;9100 Herisau, ZS Zertifikate, Schützenstrasse 1 +BE;3000 Bern 8, GSI, Postfach 552 +BL;4410 Liestal, VGD BL, Postfach 639 +BS;4001 Basel, GD BS, Postfach 2048 +FR;1752 Villars-sur-Glâne, SMC, Rte de Villars 101 +GE;1207 Genève, DGS, Service du Médecin Cantonal, rue Adrien-Lachenal 8 +GL;8750 Glarus, DFG, Postfach 768 \ No newline at end of file