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 extends Exception> 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 extends Exception> 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 extends Exception> 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 extends Exception> 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