diff --git a/docs/onboarding/Configuration-Properties.md b/docs/onboarding/Configuration-Properties.md
index cbd0952d0..1918751fb 100644
--- a/docs/onboarding/Configuration-Properties.md
+++ b/docs/onboarding/Configuration-Properties.md
@@ -137,19 +137,21 @@ The Onboarding Server uses the following public configuration properties:
## Innovatrics Configuration
-| Property | Default | Note |
-|--------------------------------------------------------------------------------------------------|---------------------------|--------------------------------------------------------------------------------|
-| `enrollment-server-onboarding.provider.innovatrics.serviceBaseUrl` | | Base REST service URL for Innovatrics. |
-| `enrollment-server-onboarding.provider.innovatrics.serviceToken` | | Authentication token for Innovatrics. |
-| `enrollment-server-onboarding.provider.innovatrics.serviceUserAgent` | `Wultra/OnboardingServer` | User agent to use when making HTTP calls to Innovatrics REST service. |
-| `enrollment-server-onboarding.provider.innovatrics.presenceCheck.score` | 0.875 | Presence check minimal score threshold. |
-| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.acceptInvalidSslCertificate` | `false` | Whether invalid SSL certificate is accepted when calling Zen ID REST service. |
-| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.maxInMemorySize` | `10485760` | Maximum in memory size of HTTP requests when calling Innovatrics REST service. |
-| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyEnabled` | `false` | Whether proxy server is enabled when calling Innovatrics REST service. |
-| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyHost` | | Proxy host to be used when calling Innovatrics REST service. |
-| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyPort` | 0 | Proxy port to be used when calling Innovatrics REST service. |
-| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyUsername` | | Proxy username to be used when calling Innovatrics REST service. |
-| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyPassword` | | Proxy password to be used when calling Innovatrics REST service. |
+| Property | Default | Note |
+|---------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `enrollment-server-onboarding.provider.innovatrics.serviceBaseUrl` | | Base REST service URL for Innovatrics. |
+| `enrollment-server-onboarding.provider.innovatrics.serviceToken` | | Authentication token for Innovatrics. |
+| `enrollment-server-onboarding.provider.innovatrics.serviceUserAgent` | `Wultra/OnboardingServer` | User agent to use when making HTTP calls to Innovatrics REST service. |
+| `enrollment-server-onboarding.provider.innovatrics.presenceCheckConfiguration.score` | 0.875 | Presence check minimal score threshold. |
+| `enrollment-server-onboarding.provider.innovatrics.documentVerificationConfiguration.documentCountries` | `CZE` | List of expected countries of issue of identification documents as three-letter country codes, i.e. ISO 3166-1 alpha-3. If empty, all countries of issue known to Innovatrics are considered during classification, which may have negative impact on performance. |
+| `enrollment-server-onboarding.provider.innovatrics.document-verification-configuration.crucialFields` | `documentNumber`, `dateOfIssue`, `dateOfExpiry`, `surname`, `dateOfBirth`, `personalNumber`, `givenNames` | Set of fields in camelCase that are cross-validated between the machine-readable zone and visual zone. Only those specified fields that are actually extracted from a document are considered. If empty, no inconsistencies will be checked. |
+| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.acceptInvalidSslCertificate` | `false` | Whether invalid SSL certificate is accepted when calling Zen ID REST service. |
+| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.maxInMemorySize` | `10485760` | Maximum in memory size of HTTP requests when calling Innovatrics REST service. |
+| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyEnabled` | `false` | Whether proxy server is enabled when calling Innovatrics REST service. |
+| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyHost` | | Proxy host to be used when calling Innovatrics REST service. |
+| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyPort` | 0 | Proxy port to be used when calling Innovatrics REST service. |
+| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyUsername` | | Proxy username to be used when calling Innovatrics REST service. |
+| `enrollment-server-onboarding.provider.innovatrics.restClientConfig.proxyPassword` | | Proxy password to be used when calling Innovatrics REST service. |
See [Innovatrics documentation](https://developers.innovatrics.com/digital-onboarding/docs/functionalities/face/active-liveness-check/#magnifeye-liveness) for details how the score affects false acceptances (FAR) and false rejections (FRR).
diff --git a/docs/onboarding/Configuration-Verification-Providers.md b/docs/onboarding/Configuration-Verification-Providers.md
index ea4f83544..ed358b8bd 100644
--- a/docs/onboarding/Configuration-Verification-Providers.md
+++ b/docs/onboarding/Configuration-Verification-Providers.md
@@ -6,6 +6,7 @@ This document describes configuration of providers for personal identity documen
The document verification process is currently supported for following providers:
- [ZenID](https://zenid.trask.cz/) - use value `zenid` in configuration
+- [Innovatrics](https://www.innovatrics.com/) - use value `innovatrics` in configuration
- Mock - useful for simple testing and local runs - use value `mock` in configuration
### ZenID
@@ -35,6 +36,7 @@ When calling `document-verification/init-sdk` following implementation fields ar
The document verification process is currently supported for following providers:
- [iProov](https://www.iproov.com/) - use value `iproov` in configuration
+- [Innovatrics](https://www.innovatrics.com/) - use value `innovatrics` in configuration
- Mock - useful for simple testing and local runs - use value `mock` in configuration
#### Configuration
diff --git a/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/PresenceCheckProvider.java b/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/PresenceCheckProvider.java
index 298b5296b..2a4ace4ac 100644
--- a/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/PresenceCheckProvider.java
+++ b/enrollment-server-onboarding-api/src/main/java/com/wultra/app/onboardingserver/api/provider/PresenceCheckProvider.java
@@ -44,13 +44,13 @@ public interface PresenceCheckProvider {
void initPresenceCheck(OwnerId id, Image photo) throws PresenceCheckException, RemoteCommunicationException;
/**
- * A feature flag whether the trusted photo of the user should be passed to {@link #initPresenceCheck(OwnerId, Image)}.
+ * Configuration flag setting where the provider implementation expects the trusted photo of the user.
*
* Some implementation may require specific source to be called by Onboarding server, some providers may handle it internally.
*
- * @return {@code true} if the trusted photo should be provided, {@code false} otherwise.
+ * @return Source where the trusted photo is expected.
*/
- boolean shouldProvideTrustedPhoto();
+ TrustedPhotoSource trustedPhotoSource();
/**
* Starts the presence check process. The process has to be initialized before this call.
@@ -82,4 +82,18 @@ public interface PresenceCheckProvider {
*/
void cleanupIdentityData(OwnerId id, SessionInfo sessionInfo) throws PresenceCheckException, RemoteCommunicationException;
+ /**
+ * Return type for {@link #trustedPhotoSource()}.
+ */
+ enum TrustedPhotoSource {
+ /**
+ * If the trusted photo should be passed in {@link #initPresenceCheck}
+ */
+ IMAGE,
+
+ /**
+ * If the trusted photo is passed via reference in {@link SessionInfo}
+ */
+ REFERENCE
+ }
}
diff --git a/enrollment-server-onboarding-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java b/enrollment-server-onboarding-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java
index 1dfec196f..89aed8802 100644
--- a/enrollment-server-onboarding-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java
+++ b/enrollment-server-onboarding-domain-model/src/main/java/com/wultra/app/enrollmentserver/model/integration/SessionInfo.java
@@ -30,6 +30,11 @@
@Data
public class SessionInfo {
+ public static final String ATTRIBUTE_TIMESTAMP_LAST_USED = "timestampLastUsed";
+ public static final String ATTRIBUTE_IMAGE_UPLOADED = "imageUploaded";
+ public static final String ATTRIBUTE_PRIMARY_DOCUMENT_REFERENCE = "primaryDocumentReference";
+ public static final String ATTRIBUTE_OTHER_DOCUMENTS_REFERENCES = "otherDocumentsReferences";
+
private Map sessionAttributes = new LinkedHashMap<>();
}
diff --git a/enrollment-server-onboarding-provider-innovatrics/pom.xml b/enrollment-server-onboarding-provider-innovatrics/pom.xml
index 73dd6c2b0..bec000cb1 100644
--- a/enrollment-server-onboarding-provider-innovatrics/pom.xml
+++ b/enrollment-server-onboarding-provider-innovatrics/pom.xml
@@ -42,6 +42,18 @@
spring-boot-starter-test
test
+
+
+ com.squareup.okhttp3
+ mockwebserver
+ test
+
+
+
+ com.h2database
+ h2
+ test
+
diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsApiService.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsApiService.java
index 8d99e710e..916c08fb7 100644
--- a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsApiService.java
+++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsApiService.java
@@ -18,7 +18,13 @@
package com.wultra.app.onboardingserver.provider.innovatrics;
import com.wultra.app.enrollmentserver.model.integration.OwnerId;
+import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException;
import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException;
+import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CustomerInspectResponse;
+import com.wultra.app.onboardingserver.provider.innovatrics.model.api.EvaluateCustomerLivenessRequest;
+import com.wultra.app.onboardingserver.provider.innovatrics.model.api.EvaluateCustomerLivenessResponse;
+import com.wultra.app.enrollmentserver.model.enumeration.CardSide;
+import com.wultra.app.enrollmentserver.model.enumeration.DocumentType;
import com.wultra.app.onboardingserver.provider.innovatrics.model.api.*;
import com.wultra.core.rest.client.base.RestClient;
import com.wultra.core.rest.client.base.RestClientException;
@@ -29,11 +35,15 @@
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
+import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
+import java.util.List;
+import java.util.Optional;
+
/**
* Implementation of the REST service toInnovatrics.
*
@@ -41,6 +51,7 @@
* Both providers, document verifier and presence check, must be configured to {@code innovatrics}.
*
* @author Lubos Racansky, lubos.racansky@wultra.com
+ * @author Jan Pesek, jan.pesek@wultra.com
*/
@ConditionalOnExpression("""
'${enrollment-server-onboarding.presence-check.provider}' == 'innovatrics' and '${enrollment-server-onboarding.document-verification.provider}' == 'innovatrics'
@@ -49,8 +60,6 @@
@Slf4j
class InnovatricsApiService {
- private static final MultiValueMap EMPTY_ADDITIONAL_HEADERS = new LinkedMultiValueMap<>();
-
private static final MultiValueMap EMPTY_QUERY_PARAMS = new LinkedMultiValueMap<>();
/**
@@ -58,14 +67,21 @@ class InnovatricsApiService {
*/
private final RestClient restClient;
+ /**
+ * Configuration properties.
+ */
+ private final InnovatricsConfigProps configProps;
+
/**
* Service constructor.
*
* @param restClient REST template for Innovatrics calls.
*/
@Autowired
- public InnovatricsApiService(@Qualifier("restClientInnovatrics") final RestClient restClient) {
+ public InnovatricsApiService(@Qualifier("restClientInnovatrics") final RestClient restClient,
+ InnovatricsConfigProps configProps) {
this.restClient = restClient;
+ this.configProps = configProps;
}
public EvaluateCustomerLivenessResponse evaluateLiveness(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException {
@@ -77,7 +93,7 @@ public EvaluateCustomerLivenessResponse evaluateLiveness(final String customerId
try {
logger.info("Calling liveness/evaluation, {}", ownerId);
logger.debug("Calling {}, {}", apiPath, request);
- final ResponseEntity response = restClient.post(apiPath, request, EMPTY_QUERY_PARAMS, EMPTY_ADDITIONAL_HEADERS, new ParameterizedTypeReference<>() {});
+ final ResponseEntity response = restClient.post(apiPath, request, new ParameterizedTypeReference<>() {});
logger.info("Got {} for liveness/evaluation, {}", response.getStatusCode(), ownerId);
logger.debug("{} response status code: {}", apiPath, response.getStatusCode());
logger.trace("{} response: {}", apiPath, response);
@@ -96,7 +112,7 @@ public CustomerInspectResponse inspectCustomer(final String customerId, final Ow
try {
logger.info("Calling /inspect, {}", ownerId);
logger.debug("Calling {}", apiPath);
- final ResponseEntity response = restClient.post(apiPath, null, EMPTY_QUERY_PARAMS, EMPTY_ADDITIONAL_HEADERS, new ParameterizedTypeReference<>() {});
+ final ResponseEntity response = restClient.post(apiPath, null, new ParameterizedTypeReference<>() {});
logger.info("Got {} for /inspect, {}", response.getStatusCode(), ownerId);
logger.debug("{} response status code: {}", apiPath, response.getStatusCode());
logger.trace("{} response: {}", apiPath, response);
@@ -206,4 +222,255 @@ public CreateSelfieResponse createSelfie(final String customerId, final String l
}
}
+ // TODO remove - temporal test call
+// @PostConstruct
+// public void testCall() throws RestClientException {
+// logger.info("Trying a test call");
+// final ResponseEntity response = restClient.get("/api/v1/metadata", STRING_TYPE_REFERENCE);
+// logger.info("Result of test call: {}", response.getBody());
+// }
+
+ /**
+ * Create a new customer resource.
+ * @param ownerId owner identification.
+ * @return optional of CreateCustomerResponse with a customerId.
+ * @throws RemoteCommunicationException in case of 4xx or 5xx response status code.
+ */
+ public CreateCustomerResponse createCustomer(final OwnerId ownerId) throws RemoteCommunicationException {
+ final String apiPath = "/api/v1/customers";
+
+ try {
+ logger.info("Creating customer, {}", ownerId);
+ logger.debug("Calling {}", apiPath);
+ final ResponseEntity response = restClient.post(apiPath, null, new ParameterizedTypeReference<>() {});
+ logger.info("Got {} for creating customer, {}", response.getStatusCode(), ownerId);
+ logger.debug("{} response status code: {}", apiPath, response.getStatusCode());
+ logger.trace("{} response: {}", apiPath, response);
+ return response.getBody();
+ } catch (RestClientException e) {
+ throw new RemoteCommunicationException("REST API call failed when creating a new customer resource, statusCode=%s, responseBody='%s'".formatted(e.getStatusCode(), e.getResponse()), e);
+ } catch (Exception e) {
+ throw new RemoteCommunicationException("Unexpected error when creating a new customer resource", e);
+ }
+ }
+
+ /**
+ * Create a new document resource assigned to a customer. This resource is used for documents only, not for selfies.
+ * @param customerId id of the customer to assign the resource to.
+ * @param documentType type of document that will be uploaded later.
+ * @param ownerId owner identification.
+ * @return optional of CreateDocumentResponse. Does not contain important details.
+ * @throws RemoteCommunicationException in case of 4xx or 5xx response status code.
+ */
+ public CreateDocumentResponse createDocument(final String customerId, final DocumentType documentType, final OwnerId ownerId) throws RemoteCommunicationException, DocumentVerificationException {
+ final String apiPath = "/api/v1/customers/%s/document".formatted(customerId);
+
+ final DocumentClassificationAdvice classificationAdvice = new DocumentClassificationAdvice();
+ classificationAdvice.setTypes(List.of(convertType(documentType)));
+ classificationAdvice.setCountries(configProps.getDocumentVerificationConfiguration().getDocumentCountries());
+ final DocumentAdvice advice = new DocumentAdvice();
+ advice.setClassification(classificationAdvice);
+ final CreateDocumentRequest request = new CreateDocumentRequest();
+ request.setAdvice(advice);
+
+ try {
+ logger.info("Creating new document of type {} for customer {}, {}", documentType, customerId, ownerId);
+ logger.debug("Calling {}, {}", apiPath, request);
+ final ResponseEntity response = restClient.put(apiPath, request, new ParameterizedTypeReference<>() {});
+ logger.info("Got {} for creating document, {}", response.getStatusCode(), ownerId);
+ logger.debug("{} response status code: {}", apiPath, response.getStatusCode());
+ logger.trace("{} response: {}", apiPath, response);
+ return response.getBody();
+ } catch (RestClientException e) {
+ throw new RemoteCommunicationException("REST API call failed when creating a new document resource for customerId=%s, statusCode=%s, responseBody='%s'".formatted(customerId, e.getStatusCode(), e.getResponse()), e);
+ } catch (Exception e) {
+ throw new RemoteCommunicationException("Unexpected error when creating a new document resource for customerId=%s".formatted(customerId), e);
+ }
+ }
+
+ /**
+ * Provide photo of a document page. A document resource must be already assigned to the customer.
+ * @param customerId id of the customer to whom the document should be provided.
+ * @param side specifies side of the document.
+ * @param imageBytes image of the page encoded in base64.
+ * @param ownerId owner identification.
+ * @return optional of CreateDocumentPageResponse with details extracted from the page.
+ * @throws RemoteCommunicationException in case of 4xx or 5xx response status code.
+ */
+ public CreateDocumentPageResponse provideDocumentPage(final String customerId, final CardSide side, final byte[] imageBytes, final OwnerId ownerId) throws RemoteCommunicationException {
+ final String apiPath = "/api/v1/customers/%s/document/pages".formatted(customerId);
+
+ final DocumentPageClassificationAdvice classificationAdvice = new DocumentPageClassificationAdvice();
+ classificationAdvice.setPageTypes(List.of(convertSide(side)));
+ final DocumentPageAdvice advice = new DocumentPageAdvice();
+ advice.setClassification(classificationAdvice);
+
+ final Image image = new Image();
+ image.setData(imageBytes);
+
+ final CreateDocumentPageRequest request = new CreateDocumentPageRequest();
+ request.setAdvice(advice);
+ request.setImage(image);
+
+ try {
+ logger.info("Providing {} side document page for customer {}, {}", convertSide(side), customerId, ownerId);
+ logger.debug("Calling {}, {}", apiPath, request);
+ final ResponseEntity response = restClient.put(apiPath, request, new ParameterizedTypeReference<>() {});
+ logger.info("Got {} for providing document page, {}", response.getStatusCode(), ownerId);
+ logger.debug("{} response status code: {}", apiPath, response.getStatusCode());
+ logger.trace("{} response: {}", apiPath, response);
+ return response.getBody();
+ } catch (RestClientException e) {
+ throw new RemoteCommunicationException("REST API call failed when providing a document page for customerId=%s, statusCode=%s, responseBody='%s'".formatted(customerId, e.getStatusCode(), e.getResponse()), e);
+ } catch (Exception e) {
+ throw new RemoteCommunicationException("Unexpected error when providing a document page for customerId=%s".formatted(customerId), e);
+ }
+ }
+
+ /**
+ * Get details gathered about the customer.
+ * @param customerId id of the customer.
+ * @param ownerId owner identification.
+ * @return optional of GetCustomerResponse with details about the customer.
+ * @throws RemoteCommunicationException in case of 4xx or 5xx response status code.
+ */
+ public GetCustomerResponse getCustomer(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException {
+ final String apiPath = "/api/v1/customers/%s".formatted(customerId);
+
+ try {
+ logger.info("Getting details about customer {}, {}", customerId, ownerId);
+ logger.debug("Calling {}", apiPath);
+ final ResponseEntity response = restClient.get(apiPath, new ParameterizedTypeReference<>() {});
+ logger.info("Got {} for getting details about customer, {}", response.getStatusCode(), ownerId);
+ logger.debug("{} response status code: {}", apiPath, response.getStatusCode());
+ logger.trace("{} response: {}", apiPath, response);
+ return response.getBody();
+ } catch (RestClientException e) {
+ throw new RemoteCommunicationException("REST API call failed when getting details of customerId=%s, statusCode=%s, responseBody='%s'".formatted(customerId, e.getStatusCode(), e.getResponse()), e);
+ } catch (Exception e) {
+ throw new RemoteCommunicationException("Unexpected error when getting details of customerId=%s".formatted(customerId), e);
+ }
+ }
+
+ /**
+ * Get document portrait of the customer.
+ * @param customerId id of the customer.
+ * @param ownerId owner identification.
+ * @return successful Response contains a base64 in the JPG format.
+ * @throws RemoteCommunicationException in case of 4xx or 5xx response status code.
+ */
+ public Optional getDocumentPortrait(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException {
+ final String apiPath = "/api/v1/customers/%s/document/portrait".formatted(customerId);
+
+ try {
+ logger.info("Getting document portrait of customer {}, {}", customerId, ownerId);
+ logger.debug("Calling {}", apiPath);
+ final ResponseEntity response = restClient.get(apiPath, new ParameterizedTypeReference<>() {});
+ logger.info("Got {} for getting document portrait, {}", response.getStatusCode(), ownerId);
+ logger.debug("{} response status code: {}", apiPath, response.getStatusCode());
+ logger.trace("{} response: {}", apiPath, response);
+ return Optional.ofNullable(response.getBody());
+ } catch (RestClientException e) {
+ if (HttpStatus.NOT_FOUND == e.getStatusCode()) {
+ // API returns 404 in case of missing portrait photo.
+ logger.debug("Missing portrait photo for customer {}, {}", customerId, ownerId);
+ return Optional.empty();
+ }
+ throw new RemoteCommunicationException("REST API call failed when getting customer portrait, statusCode=%s, responseBody='%s'".formatted(e.getStatusCode(), e.getResponse()), e);
+ }
+ }
+
+ /**
+ * Inspect consistency of data of the submitted document provided for a customer.
+ * @param customerId id of the customer whose document to inspect.
+ * @param ownerId owner identification.
+ * @return optional of DocumentInspectResponse with details about consistency of the document.
+ * @throws RemoteCommunicationException in case of 4xx or 5xx response status code.
+ */
+ public DocumentInspectResponse inspectDocument(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException {
+ final String apiPath = "/api/v1/customers/%s/document/inspect".formatted(customerId);
+
+ try {
+ logger.info("Getting document inspect of customer {}, {}", customerId, ownerId);
+ logger.debug("Calling {}", apiPath);
+ final ResponseEntity response = restClient.post(apiPath, null, new ParameterizedTypeReference<>() {});
+ logger.info("Got {} for getting document inspect, {}", response.getStatusCode(), ownerId);
+ logger.debug("{} response status code: {}", apiPath, response.getStatusCode());
+ logger.trace("{} response: {}", apiPath, response);
+ return response.getBody();
+ } catch (RestClientException e) {
+ throw new RemoteCommunicationException("REST API call failed while getting document inspection for customerId=%s, statusCode=%s, responseBody='%s'".formatted(customerId, e.getStatusCode(), e.getResponse()), e);
+ } catch (Exception e) {
+ throw new RemoteCommunicationException("Unexpected error when getting document inspection for customerId=%s".formatted(customerId), e);
+ }
+ }
+
+ /**
+ * Delete customer.
+ * @param customerId id of the customer.
+ * @param ownerId owner identification.
+ * @throws RemoteCommunicationException in case of 4xx or 5xx response status code.
+ */
+ public void deleteCustomer(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException {
+ final String apiPath = "/api/v1/customers/%s".formatted(customerId);
+
+ try {
+ logger.info("Deleting customer {}, {}", customerId, ownerId);
+ logger.debug("Calling {}", apiPath);
+ final ResponseEntity response = restClient.delete(apiPath, new ParameterizedTypeReference<>() {});
+ logger.info("Got {} for deleting customer, {}", response.getStatusCode(), ownerId);
+ logger.debug("{} response status code: {}", apiPath, response.getStatusCode());
+ logger.trace("{} response: {}", apiPath, response);
+ } catch (RestClientException e) {
+ throw new RemoteCommunicationException("REST API call failed when deleting customer, statusCode=%s, responseBody='%s'".formatted(e.getStatusCode(), e.getResponse()), e);
+ }
+ }
+
+ /**
+ * Delete customer's document.
+ * @param customerId id of the customer.
+ * @param ownerId owner identification.
+ * @throws RemoteCommunicationException in case of 4xx or 5xx response status code.
+ */
+ public void deleteDocument(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException {
+ final String apiPath = "/api/v1/customers/%s/document".formatted(customerId);
+
+ try {
+ logger.info("Deleting document of customer {}, {}", customerId, ownerId);
+ logger.debug("Calling {}", apiPath);
+ final ResponseEntity response = restClient.delete(apiPath, new ParameterizedTypeReference<>() {});
+ logger.info("Got {} for deleting customer's document, {}", response.getStatusCode(), ownerId);
+ logger.debug("{} response status code: {}", apiPath, response.getStatusCode());
+ logger.trace("{} response: {}", apiPath, response);
+ } catch (RestClientException e) {
+ throw new RemoteCommunicationException("REST API call failed when deleting customer's document, statusCode=%s, responseBody='%s'".formatted(e.getStatusCode(), e.getResponse()), e);
+ }
+ }
+
+ /**
+ * Converts internal DocumentType enum to string value used by Innovatrics.
+ * @param type represents type of document.
+ * @return document type as a string value.
+ */
+ private static String convertType(DocumentType type) throws DocumentVerificationException {
+ return switch (type) {
+ case ID_CARD -> "identity-card";
+ case PASSPORT -> "passport";
+ case DRIVING_LICENSE -> "drivers-licence";
+ default -> throw new DocumentVerificationException("Unsupported documentType " + type);
+ };
+ }
+
+ /**
+ * Converts internal CardSide enum to string value used by Innovatrics.
+ * @param side represents side of a card.
+ * @return side of a card as a string value.
+ */
+ private static String convertSide(CardSide side) {
+ return switch (side) {
+ case FRONT -> "front";
+ case BACK -> "back";
+ };
+ }
+
}
diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsConfigProps.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsConfigProps.java
index 62fb7f67c..ce0acde45 100644
--- a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsConfigProps.java
+++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsConfigProps.java
@@ -24,6 +24,9 @@
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
+import java.util.List;
+import java.util.Set;
+
/**
* Innovatrics configuration properties.
*
@@ -62,6 +65,8 @@ class InnovatricsConfigProps {
private PresenceCheckConfiguration presenceCheckConfiguration;
+ private DocumentVerificationConfiguration documentVerificationConfiguration;
+
@Getter @Setter
public static class PresenceCheckConfiguration {
/**
@@ -70,4 +75,17 @@ public static class PresenceCheckConfiguration {
private double score = 0.875;
}
+ @Getter @Setter
+ public static class DocumentVerificationConfiguration {
+ /**
+ * Identifies expected document countries of issue in ISO 3166-1 alpha-3 format.
+ */
+ private List documentCountries;
+
+ /**
+ * Set of fields in camelCase that are cross-validated between the machine-readable zone and visual zone, if extracted.
+ */
+ private Set crucialFields;
+ }
+
}
diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProvider.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProvider.java
index 70d177e73..e3d134135 100644
--- a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProvider.java
+++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProvider.java
@@ -17,35 +17,100 @@
*/
package com.wultra.app.onboardingserver.provider.innovatrics;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.base.Strings;
+import com.wultra.app.enrollmentserver.model.enumeration.DocumentType;
+import com.wultra.app.enrollmentserver.model.enumeration.DocumentVerificationStatus;
import com.wultra.app.enrollmentserver.model.integration.*;
+import com.wultra.app.enrollmentserver.model.integration.Image;
import com.wultra.app.onboardingserver.api.errorhandling.DocumentVerificationException;
import com.wultra.app.onboardingserver.api.provider.DocumentVerificationProvider;
import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity;
import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity;
import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import com.wultra.app.onboardingserver.provider.innovatrics.model.api.*;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.stereotype.Component;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
+import java.util.stream.Collectors;
/**
* Implementation of the {@link DocumentVerificationProvider} with Innovatrics.
*
* @author Jan Pesek, jan.pesek@wultra.com
*/
-@ConditionalOnProperty(value = "enrollment-server-onboarding.document-verification.provider", havingValue = "innovatrics")
+@ConditionalOnExpression("""
+ '${enrollment-server-onboarding.presence-check.provider}' == 'innovatrics' and '${enrollment-server-onboarding.document-verification.provider}' == 'innovatrics'
+ """)
@Component
-class InnovatricsDocumentVerificationProvider implements DocumentVerificationProvider {
+@AllArgsConstructor
+@Slf4j
+public class InnovatricsDocumentVerificationProvider implements DocumentVerificationProvider {
+
+ private final InnovatricsApiService innovatricsApiService;
+ private final ObjectMapper objectMapper;
+ private final InnovatricsConfigProps configuration;
@Override
public DocumentsSubmitResult checkDocumentUpload(OwnerId id, DocumentVerificationEntity document) throws RemoteCommunicationException, DocumentVerificationException {
- return null;
+ logger.warn("Unexpected state of document {}, {}", document, id);
+ throw new UnsupportedOperationException("Method checkDocumentUpload is not supported by Innovatrics provider.");
}
@Override
public DocumentsSubmitResult submitDocuments(OwnerId id, List documents) throws RemoteCommunicationException, DocumentVerificationException {
- return null;
+ if (CollectionUtils.isEmpty(documents)) {
+ logger.info("Empty documents list passed to document provider, {}", id);
+ return new DocumentsSubmitResult();
+ }
+
+ final DocumentType documentType = documents.get(0).getType();
+ if (DocumentType.SELFIE_PHOTO == documentType) {
+ logger.debug("Selfie photo passed as a document, {}", id);
+ throw new DocumentVerificationException("Selfie photo cannot be submitted as a document");
+ }
+
+ if (DocumentType.SELFIE_VIDEO == documentType) {
+ logger.debug("Selfie video passed as a document, {}", id);
+ throw new DocumentVerificationException("Selfie video cannot be submitted as a document");
+ }
+
+ final String customerId = createCustomer(id);
+ createDocument(customerId, documentType, id);
+ logger.debug("Created new customer {}, {}", customerId, id);
+
+ final DocumentsSubmitResult results = new DocumentsSubmitResult();
+ for (SubmittedDocument page : documents) {
+ final CreateDocumentPageResponse createDocumentPageResponse = provideDocumentPage(customerId, page, id);
+ if (containsError(createDocumentPageResponse)) {
+ logger.debug("Page upload was not successful, {}", id);
+ results.getResults().add(createErrorSubmitResult(customerId, createDocumentPageResponse, page));
+ } else {
+ logger.debug("Document page was read successfully by provider, {}", id);
+ results.getResults().add(createSubmitResult(customerId, page));
+ }
+ }
+
+ final Optional primaryPage = results.getResults().stream()
+ .filter(result -> Strings.isNullOrEmpty(result.getRejectReason()) && Strings.isNullOrEmpty(result.getErrorDetail()))
+ .findFirst();
+
+ if (primaryPage.isPresent()) {
+ // Only first found successfully submitted page has extracted data, others has empty JSON
+ primaryPage.get().setExtractedData(getExtractedData(customerId, id));
+ if (hasDocumentPortrait(customerId, id)) {
+ results.setExtractedPhotoId(customerId);
+ }
+ }
+
+ return results;
}
@Override
@@ -55,31 +120,303 @@ public boolean shouldStoreSelfie() {
@Override
public DocumentsVerificationResult verifyDocuments(OwnerId id, List uploadIds) throws RemoteCommunicationException, DocumentVerificationException {
- return null;
+ final DocumentsVerificationResult results = new DocumentsVerificationResult();
+ results.setResults(new ArrayList<>());
+
+ // Pages of the same document have same uploadId (= customerId), no reason to generate verification for each one.
+ final List distinctUploadIds = uploadIds.stream().distinct().toList();
+ for (String customerId : distinctUploadIds) {
+ final DocumentVerificationResult result = createVerificationResult(customerId, id);
+ results.getResults().add(result);
+ }
+
+ final String rejectReasons = results.getResults().stream()
+ .map(DocumentVerificationResult::getRejectReason)
+ .filter(StringUtils::hasText)
+ .collect(Collectors.joining(";"));
+ if (StringUtils.hasText(rejectReasons)) {
+ logger.debug("Some documents were rejected: rejectReasons={}, {}", rejectReasons, id);
+ results.setStatus(DocumentVerificationStatus.REJECTED);
+ results.setRejectReason(rejectReasons);
+ } else {
+ logger.debug("All documents accepted, {}", id);
+ results.setStatus(DocumentVerificationStatus.ACCEPTED);
+ }
+ results.setVerificationId(UUID.randomUUID().toString());
+ return results;
}
@Override
public DocumentsVerificationResult getVerificationResult(OwnerId id, String verificationId) throws RemoteCommunicationException, DocumentVerificationException {
- return null;
+ logger.warn("Unexpected state of documents with verificationId={}, {}", verificationId, id);
+ throw new UnsupportedOperationException("Method getVerificationResult is not supported by Innovatrics provider.");
}
@Override
public Image getPhoto(String photoId) throws RemoteCommunicationException, DocumentVerificationException {
- return null;
+ logger.warn("Unexpected document portrait query for customerId={}", photoId);
+ throw new UnsupportedOperationException("Method getPhoto is not implemented by Innovatrics provider.");
}
@Override
public void cleanupDocuments(OwnerId id, List uploadIds) throws RemoteCommunicationException, DocumentVerificationException {
-
+ // Pages of the same document have same uploadId (= customerId), no reason to call delete for each one.
+ final List distinctUploadIds = uploadIds.stream().distinct().toList();
+ logger.info("Invoked cleanupDocuments, {}", id);
+ for (String customerId : distinctUploadIds) {
+ innovatricsApiService.deleteCustomer(customerId, id);
+ }
}
@Override
public List parseRejectionReasons(DocumentResultEntity docResult) throws DocumentVerificationException {
- return null;
+ logger.debug("Parsing rejection reasons of {}", docResult);
+ final String rejectionReasons = docResult.getRejectReason();
+ if (!StringUtils.hasText(rejectionReasons)) {
+ return Collections.emptyList();
+ }
+
+ return deserializeFromString(rejectionReasons);
}
@Override
public VerificationSdkInfo initVerificationSdk(OwnerId id, Map initAttributes) throws RemoteCommunicationException, DocumentVerificationException {
- return null;
+ logger.debug("#initVerificationSdk does nothing for Innovatrics, {}", id);
+ return new VerificationSdkInfo();
+ }
+
+ /**
+ * Create a new customer resource.
+ * @param ownerId owner identification.
+ * @return ID of the new customer.
+ * @throws RemoteCommunicationException if the resource was not created properly.
+ */
+ private String createCustomer(final OwnerId ownerId) throws RemoteCommunicationException {
+ return innovatricsApiService.createCustomer(ownerId).getId();
+ }
+
+ /**
+ * Create a new document resource to an existing customer.
+ * @param customerId id of the customer to assign the resource to.
+ * @param documentType type of the document that will be uploaded later.
+ * @param ownerId owner identification.
+ * @throws RemoteCommunicationException if the resource was not created properly.
+ */
+ private void createDocument(final String customerId, final DocumentType documentType, final OwnerId ownerId) throws RemoteCommunicationException, DocumentVerificationException {
+ innovatricsApiService.createDocument(customerId, documentType, ownerId);
+ }
+
+ /**
+ * Upload a page of a document to a customer.
+ * @param customerId id of the customer to whom upload the document page.
+ * @param page SubmittedDocument object representing the page.
+ * @param ownerId owner identification.
+ * @return CreateDocumentPageResponse containing info about the document type. An unsuccessful response will contain an error code.
+ * @throws RemoteCommunicationException if the document page was not uploaded properly.
+ */
+ private CreateDocumentPageResponse provideDocumentPage(final String customerId, final SubmittedDocument page, final OwnerId ownerId) throws RemoteCommunicationException {
+ return innovatricsApiService.provideDocumentPage(customerId, page.getSide(), page.getPhoto().getData(), ownerId);
+ }
+
+ /**
+ * Checks if CreateDocumentPageResponse contains error or warnings.
+ * @param pageResponse response to a page upload.
+ * @return true if there is an error or warnings, false otherwise.
+ */
+ private static boolean containsError(CreateDocumentPageResponse pageResponse) {
+ return pageResponse.getErrorCode() != null || !CollectionUtils.isEmpty(pageResponse.getWarnings());
+ }
+
+ /**
+ * Creates DocumentSubmitResult with error or reject reason.
+ * @param uploadId external id of the document.
+ * @param response returned from provider.
+ * @return DocumentSubmitResult with error or reject reason.
+ * @throws DocumentVerificationException in case of rejection reason serialization error.
+ */
+ private DocumentSubmitResult createErrorSubmitResult(String uploadId, CreateDocumentPageResponse response, SubmittedDocument submitted) throws DocumentVerificationException {
+ final DocumentSubmitResult result = new DocumentSubmitResult();
+ result.setUploadId(uploadId);
+ result.setDocumentId(submitted.getDocumentId());
+
+ final List rejectionReasons = new ArrayList<>();
+ if (response.getErrorCode() != null) {
+ switch (response.getErrorCode()) {
+ case NO_CARD_CORNERS_DETECTED -> rejectionReasons.add("Document page was not detected in the photo.");
+ case PAGE_DOESNT_MATCH_DOCUMENT_TYPE_OF_PREVIOUS_PAGE -> rejectionReasons.add("Mismatched document pages types.");
+ default -> rejectionReasons.add("Unknown error: %s".formatted(response.getErrorCode().getValue()));
+ }
+ }
+
+ if (!CollectionUtils.isEmpty(response.getWarnings())) {
+ for (CreateDocumentPageResponse.WarningsEnum warning : response.getWarnings()) {
+ switch (warning) {
+ case DOCUMENT_TYPE_NOT_RECOGNIZED -> rejectionReasons.add("Document type not recognized.");
+ default -> rejectionReasons.add("Unknown warning: %s".formatted(warning.getValue()));
+ }
+ }
+ }
+
+ if (!rejectionReasons.isEmpty()) {
+ result.setRejectReason(serializeToString(rejectionReasons));
+ }
+
+ return result;
+ }
+
+ /**
+ * Gets all customer data extracted from uploaded documents in a JSON form.
+ * @param customerId id of the customer.
+ * @param ownerId owner identification.
+ * @return JSON serialized data.
+ * @throws RemoteCommunicationException in case of the remote service error.
+ * @throws DocumentVerificationException if the returned data could not be provided.
+ */
+ private String getExtractedData(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException, DocumentVerificationException {
+ return serializeToString(innovatricsApiService.getCustomer(customerId, ownerId));
}
+
+ /**
+ * Checks if a document portrait of the customer is available.
+ * @param customerId id of the customer.
+ * @param ownerId owner identification.
+ * @return true if document portrait is available, false otherwise.
+ */
+ private boolean hasDocumentPortrait(final String customerId, final OwnerId ownerId) throws RemoteCommunicationException {
+ return innovatricsApiService.getDocumentPortrait(customerId, ownerId).isPresent();
+ }
+
+ /**
+ * Creates DocumentSubmitResult containing extracted data.
+ * @param customerId id of the customer to get data from.
+ * @return DocumentSubmitResult containing extracted data.
+ */
+ private static DocumentSubmitResult createSubmitResult(final String customerId, final SubmittedDocument submitted) {
+ final DocumentSubmitResult result = new DocumentSubmitResult();
+ result.setUploadId(customerId);
+ result.setDocumentId(submitted.getDocumentId());
+ result.setExtractedData(DocumentSubmitResult.NO_DATA_EXTRACTED);
+ return result;
+ }
+
+ /**
+ * Gets document inspection from Innovatrics and parses it to the verification result.
+ * @param customerId id of the customer the document belongs to.
+ * @param ownerId owner identification.
+ * @return DocumentVerificationResult
+ */
+ private DocumentVerificationResult createVerificationResult(String customerId, OwnerId ownerId) throws DocumentVerificationException, RemoteCommunicationException {
+ final DocumentInspectResponse response = innovatricsApiService.inspectDocument(customerId, ownerId);
+
+ final List rejectionReasons = new ArrayList<>();
+ if (Boolean.TRUE.equals(response.getExpired())) {
+ rejectionReasons.add("Document expired.");
+ }
+
+ if (response.getMrzInspection() != null && !Boolean.TRUE.equals(response.getMrzInspection().getValid())) {
+ rejectionReasons.add("MRZ does not conform the ICAO specification.");
+ }
+
+ final VisualZoneInspection viz = response.getVisualZoneInspection();
+ rejectionReasons.addAll(parseVisualZoneInspection(viz));
+
+ if (response.getPageTampering() != null) {
+ response.getPageTampering().forEach((side, inspection) -> {
+ if (Boolean.TRUE.equals(inspection.getColorProfileChangeDetected())) {
+ rejectionReasons.add("Colors on the document %s does not corresponds to the expected color profile.".formatted(side));
+ }
+ if (Boolean.TRUE.equals(inspection.getLooksLikeScreenshot())) {
+ rejectionReasons.add("Provided image of the document %s was taken from a screen of another device.".formatted(side));
+ }
+ if (Boolean.TRUE.equals(inspection.getTamperedTexts())) {
+ rejectionReasons.add("Text of the document %s is tampered.".formatted(side));
+ }
+ });
+ }
+
+ final DocumentVerificationResult result = new DocumentVerificationResult();
+ result.setUploadId(customerId);
+ result.setVerificationResult(serializeToString(response));
+ if (!rejectionReasons.isEmpty()) {
+ result.setRejectReason(serializeToString(rejectionReasons));
+ }
+ return result;
+ }
+
+ /**
+ * Parse VisualZoneInspection of a document provided by Innovatrics.
+ * @param visualZoneInspection inspection of a document by Innovatrics.
+ * @return List of reasons to reject the document.
+ */
+ private List parseVisualZoneInspection(final VisualZoneInspection visualZoneInspection) {
+ final List rejectionReasons = new ArrayList<>();
+ if (visualZoneInspection == null) {
+ return rejectionReasons;
+ }
+
+ // Contains fields with a ocr confidence lower than ocr-text-field-threshold settings.
+ final List lowOcrConfidenceAttributes = visualZoneInspection.getOcrConfidence().getLowOcrConfidenceTexts();
+ if (!CollectionUtils.isEmpty(lowOcrConfidenceAttributes)) {
+ rejectionReasons.add("Low OCR confidence of attributes: %s".formatted(lowOcrConfidenceAttributes));
+ }
+
+ final TextConsistency textConsistency = visualZoneInspection.getTextConsistency();
+ if (textConsistency == null) {
+ return rejectionReasons;
+ }
+
+ final TextConsistentWith textConsistentWith = textConsistency.getConsistencyWith();
+ if (textConsistentWith == null) {
+ return rejectionReasons;
+ }
+
+ final MrzConsistency mrzConsistency = textConsistentWith.getMrz();
+ if (mrzConsistency != null) {
+ final List inconsistentAttributes = getCrucial(mrzConsistency.getInconsistentTexts());
+ if (!inconsistentAttributes.isEmpty()) {
+ rejectionReasons.add("Inconsistent crucial attributes with MRZ: %s".formatted(inconsistentAttributes));
+ }
+ }
+
+ final BarcodesConsistency barcodesConsistency = textConsistentWith.getBarcodes();
+ if (barcodesConsistency != null) {
+ final List inconsistentAttributes = getCrucial(barcodesConsistency.getInconsistentTexts());
+ if (!inconsistentAttributes.isEmpty()) {
+ rejectionReasons.add("Inconsistent crucial attributes with barcode: %s".formatted(inconsistentAttributes));
+ }
+ }
+
+ return rejectionReasons;
+ }
+
+ /**
+ * Intersects list of attributes with CRUCIAL_ATTRIBUTES.
+ * @param attributes Attributes to do the intersection on.
+ * @return Attributes intersection.
+ */
+ private List getCrucial(final List attributes) {
+ if (attributes == null) {
+ return Collections.emptyList();
+ }
+
+ final Set crucialFields = configuration.getDocumentVerificationConfiguration().getCrucialFields();
+ return attributes.stream().filter(crucialFields::contains).toList();
+ }
+
+ private String serializeToString(T src) throws DocumentVerificationException {
+ try {
+ return objectMapper.writeValueAsString(src);
+ } catch (JsonProcessingException e) {
+ throw new DocumentVerificationException("Unexpected error when serializing data", e);
+ }
+ }
+
+ private T deserializeFromString(String src) throws DocumentVerificationException {
+ try {
+ return objectMapper.readValue(src, new TypeReference<>() {});
+ } catch (JsonProcessingException e) {
+ throw new DocumentVerificationException("Unexpected error when deserializing data", e);
+ }
+ }
+
}
diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessService.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessService.java
index 833396add..8b97aa52e 100644
--- a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessService.java
+++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsLivenessService.java
@@ -33,7 +33,7 @@
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -46,11 +46,11 @@
@Transactional(readOnly = true)
@Slf4j
@AllArgsConstructor
-@ConditionalOnProperty(value = "enrollment-server-onboarding.presence-check.provider", havingValue = "innovatrics")
+@ConditionalOnExpression("""
+ '${enrollment-server-onboarding.presence-check.provider}' == 'innovatrics' and ${enrollment-server-onboarding.onboarding-process.enabled} == true
+ """)
class InnovatricsLivenessService {
- private static final String INNOVATRICS_CUSTOMER_ID = "InnovatricsCustomerId";
-
private final InnovatricsApiService innovatricsApiService;
private final IdentityVerificationRepository identityVerificationRepository;
@@ -124,8 +124,7 @@ private static String fetchCustomerId(final OwnerId id, final IdentityVerificati
throw new IdentityVerificationException("Unable to deserialize session info", e);
}
- // TODO (racansky, 2023-11-28) discuss the format with Jan Pesek, extract to common logic
- final String customerId = (String) sessionInfo.getSessionAttributes().get(INNOVATRICS_CUSTOMER_ID);
+ final String customerId = (String) sessionInfo.getSessionAttributes().get(SessionInfo.ATTRIBUTE_PRIMARY_DOCUMENT_REFERENCE);
if (Strings.isNullOrEmpty(customerId)) {
throw new IdentityVerificationException("Missing a customer ID value for calling Innovatrics, " + id);
}
diff --git a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProvider.java b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProvider.java
index 7899e776c..007c06c6a 100644
--- a/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProvider.java
+++ b/enrollment-server-onboarding-provider-innovatrics/src/main/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProvider.java
@@ -49,8 +49,6 @@
@AllArgsConstructor
class InnovatricsPresenceCheckProvider implements PresenceCheckProvider {
- private static final String INNOVATRICS_CUSTOMER_ID = "InnovatricsCustomerId";
-
private final InnovatricsApiService innovatricsApiService;
private final InnovatricsConfigProps configuration;
@@ -61,8 +59,8 @@ public void initPresenceCheck(final OwnerId id, final Image photo) {
}
@Override
- public boolean shouldProvideTrustedPhoto() {
- return false;
+ public TrustedPhotoSource trustedPhotoSource() {
+ return TrustedPhotoSource.REFERENCE;
}
@Override
@@ -152,8 +150,7 @@ private static Optional fail(final String errorDetail) {
}
private static String fetchCustomerId(final OwnerId id, final SessionInfo sessionInfo) throws PresenceCheckException {
- // TODO (racansky, 2023-11-28) discuss the format with Jan Pesek
- final String customerId = (String) sessionInfo.getSessionAttributes().get(INNOVATRICS_CUSTOMER_ID);
+ final String customerId = (String) sessionInfo.getSessionAttributes().get(SessionInfo.ATTRIBUTE_PRIMARY_DOCUMENT_REFERENCE);
if (Strings.isNullOrEmpty(customerId)) {
throw new PresenceCheckException("Missing a customer ID value for calling Innovatrics, " + id);
}
@@ -164,12 +161,7 @@ private static String fetchCustomerId(final OwnerId id, final SessionInfo sessio
public void cleanupIdentityData(final OwnerId id, final SessionInfo sessionInfo) throws PresenceCheckException, RemoteCommunicationException {
logger.info("Invoked cleanupIdentityData, {}", id);
final String customerId = fetchCustomerId(id, sessionInfo);
-
- innovatricsApiService.deleteLiveness(customerId, id);
- logger.debug("Deleted liveness, {}", id);
-
- innovatricsApiService.deleteSelfie(customerId, id);
- logger.debug("Deleted selfie, {}", id);
+ innovatricsApiService.deleteCustomer(customerId, id);
}
record PresenceCheckError(PresenceCheckStatus status, String rejectReason, String errorDetail){}
diff --git a/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProviderTest.java b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProviderTest.java
new file mode 100644
index 000000000..fa9384eba
--- /dev/null
+++ b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsDocumentVerificationProviderTest.java
@@ -0,0 +1,174 @@
+/*
+ * PowerAuth Enrollment Server
+ * Copyright (C) 2023 Wultra s.r.o.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package com.wultra.app.onboardingserver.provider.innovatrics;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.jayway.jsonpath.JsonPath;
+import com.wultra.app.enrollmentserver.model.enumeration.CardSide;
+import com.wultra.app.enrollmentserver.model.enumeration.DocumentType;
+import com.wultra.app.enrollmentserver.model.enumeration.DocumentVerificationStatus;
+import com.wultra.app.enrollmentserver.model.integration.*;
+import com.wultra.app.enrollmentserver.model.integration.Image;
+import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity;
+import com.wultra.app.onboardingserver.provider.innovatrics.model.api.*;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Test of {@link InnovatricsDocumentVerificationProvider}.
+ *
+ * @author Jan Pesek, jan.pesek@wultra.com
+ */
+@SpringBootTest(classes = EnrollmentServerTestApplication.class)
+@ActiveProfiles("test")
+class InnovatricsDocumentVerificationProviderTest {
+
+ @Autowired
+ private InnovatricsDocumentVerificationProvider tested;
+
+ @MockBean
+ private InnovatricsApiService apiService;
+
+ @Test
+ void testSubmitDocuments() throws Exception {
+ final OwnerId ownerId = createOwnerId();
+ when(apiService.createCustomer(ownerId)).thenReturn(new CreateCustomerResponse("c123"));
+
+ final Links docLink = new Links("docResource");
+ final CreateDocumentResponse documentResponse = new CreateDocumentResponse();
+ documentResponse.setLinks(docLink);
+ when(apiService.createDocument("c123", DocumentType.PASSPORT, ownerId)).thenReturn(documentResponse);
+
+ final CreateDocumentPageResponse pageResponse = new CreateDocumentPageResponse();
+ when(apiService.provideDocumentPage("c123", CardSide.FRONT, "img".getBytes(), ownerId)).thenReturn(pageResponse);
+
+ final BiometricMultiValueAttribute ageAttr = new BiometricMultiValueAttribute("42", null, null, null, "40");
+ final MultiValueAttribute surnameAttr = new MultiValueAttribute("SPECIMEN", null, null, null);
+ final Customer customer = new Customer();
+ customer.age(ageAttr).setSurname(surnameAttr);
+ final GetCustomerResponse customerResponse = new GetCustomerResponse();
+ customerResponse.customer(customer);
+ when(apiService.getCustomer("c123", ownerId)).thenReturn(customerResponse);
+
+ final SubmittedDocument doc = new SubmittedDocument();
+ doc.setType(DocumentType.PASSPORT);
+ doc.setSide(CardSide.FRONT);
+ doc.setPhoto(Image.builder().data("img".getBytes()).build());
+
+ final DocumentsSubmitResult results = tested.submitDocuments(ownerId, List.of(doc));
+ verify(apiService).getCustomer("c123", ownerId);
+ assertEquals(1, results.getResults().size());
+
+ final DocumentSubmitResult result = results.getResults().get(0);
+ assertEquals("c123", result.getUploadId());
+ assertFalse(StringUtils.hasText(result.getErrorDetail()));
+ assertFalse(StringUtils.hasText(result.getRejectReason()));
+ assertNotNull(result.getExtractedData());
+
+ assertEquals("42", JsonPath.read(result.getExtractedData(), "$.customer.age.visualZone"));
+ assertEquals("40", JsonPath.read(result.getExtractedData(), "$.customer.age.documentPortrait"));
+ assertEquals("SPECIMEN", JsonPath.read(result.getExtractedData(), "$.customer.surname.visualZone"));
+ }
+
+ @Test
+ void testSubmitDocument_handleProvideDocumentPageError() throws Exception {
+ final OwnerId ownerId = createOwnerId();
+ when(apiService.createCustomer(ownerId)).thenReturn(new CreateCustomerResponse("c123"));
+
+ final Links docLink = new Links("docResource");
+ final CreateDocumentResponse documentResponse = new CreateDocumentResponse();
+ documentResponse.setLinks(docLink);
+ when(apiService.createDocument("c123", DocumentType.PASSPORT, ownerId)).thenReturn(documentResponse);
+
+ final CreateDocumentPageResponse pageResponse = new CreateDocumentPageResponse(
+ "front",
+ CreateDocumentPageResponse.ErrorCodeEnum.NO_CARD_CORNERS_DETECTED,
+ List.of(CreateDocumentPageResponse.WarningsEnum.DOCUMENT_TYPE_NOT_RECOGNIZED));
+ when(apiService.provideDocumentPage("c123", CardSide.FRONT, "img".getBytes(), ownerId)).thenReturn(pageResponse);
+
+ final SubmittedDocument doc = new SubmittedDocument();
+ doc.setType(DocumentType.PASSPORT);
+ doc.setSide(CardSide.FRONT);
+ doc.setPhoto(Image.builder().data("img".getBytes()).build());
+
+ final DocumentsSubmitResult results = tested.submitDocuments(ownerId, List.of(doc));
+ verify(apiService).provideDocumentPage("c123", CardSide.FRONT, "img".getBytes(), ownerId);
+ assertEquals(1, results.getResults().size());
+
+ final DocumentSubmitResult result = results.getResults().get(0);
+ assertEquals("c123", result.getUploadId());
+ assertTrue(StringUtils.hasText(result.getRejectReason()));
+ }
+
+ @Test
+ void testParseRejectionReason() throws Exception {
+ final DocumentResultEntity entity = new DocumentResultEntity();
+ entity.setRejectReason(new ObjectMapper().writeValueAsString(List.of("Reason1", "Reason2")));
+ assertEquals(List.of("Reason1", "Reason2"), tested.parseRejectionReasons(entity));
+ }
+
+ @Test
+ void testParseEmptyRejectionReason() throws Exception {
+ final DocumentResultEntity entity = new DocumentResultEntity();
+ assertTrue(tested.parseRejectionReasons(entity).isEmpty());
+ }
+
+ @Test
+ void testVerifyDocuments() throws Exception {
+ final OwnerId ownerId = createOwnerId();
+ final DocumentInspectResponse response = new DocumentInspectResponse();
+ when(apiService.inspectDocument("c123", ownerId)).thenReturn(response);
+
+ final DocumentsVerificationResult result = tested.verifyDocuments(ownerId, List.of("c123"));
+ assertTrue(result.isAccepted());
+ assertEquals("c123", result.getResults().get(0).getUploadId());
+ assertNotNull(result.getVerificationId());
+ assertNotNull(result.getResults().get(0).getVerificationResult());
+ }
+
+ @Test
+ void testVerifyDocuments_expired() throws Exception {
+ final OwnerId ownerId = createOwnerId();
+ final DocumentInspectResponse response = new DocumentInspectResponse(true, null);
+ when(apiService.inspectDocument("c123", ownerId)).thenReturn(response);
+
+ final DocumentsVerificationResult result = tested.verifyDocuments(ownerId, List.of("c123"));
+ assertEquals(DocumentVerificationStatus.REJECTED, result.getStatus());
+ assertEquals(List.of("Document expired."), new ObjectMapper().readValue(result.getRejectReason(), new TypeReference>() {}));
+ assertEquals("c123", result.getResults().get(0).getUploadId());
+ assertNotNull(result.getVerificationId());
+ }
+
+ private OwnerId createOwnerId() {
+ final OwnerId ownerId = new OwnerId();
+ ownerId.setUserId("joe");
+ ownerId.setActivationId("a123");
+ return ownerId;
+ }
+
+}
diff --git a/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProviderTest.java b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProviderTest.java
index 7037ca408..ed57e1559 100644
--- a/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProviderTest.java
+++ b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsPresenceCheckProviderTest.java
@@ -148,7 +148,7 @@ void testGetResult_customerInspectionRejected() throws Exception {
private static SessionInfo createSessionInfo() {
final SessionInfo sessionInfo = new SessionInfo();
- sessionInfo.setSessionAttributes(Map.of("InnovatricsCustomerId", CUSTOMER_ID));
+ sessionInfo.setSessionAttributes(Map.of("primaryDocumentReference", CUSTOMER_ID));
return sessionInfo;
}
diff --git a/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsRestApiServiceTest.java b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsRestApiServiceTest.java
new file mode 100644
index 000000000..f6f065984
--- /dev/null
+++ b/enrollment-server-onboarding-provider-innovatrics/src/test/java/com/wultra/app/onboardingserver/provider/innovatrics/InnovatricsRestApiServiceTest.java
@@ -0,0 +1,138 @@
+/*
+ * PowerAuth Enrollment Server
+ * Copyright (C) 2023 Wultra s.r.o.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package com.wultra.app.onboardingserver.provider.innovatrics;
+
+import com.wultra.app.enrollmentserver.model.enumeration.CardSide;
+import com.wultra.app.enrollmentserver.model.integration.OwnerId;
+import com.wultra.app.onboardingserver.common.errorhandling.RemoteCommunicationException;
+import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CreateCustomerResponse;
+import com.wultra.app.onboardingserver.provider.innovatrics.model.api.CreateDocumentPageResponse;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Test of {@link InnovatricsApiService}.
+ *
+ * @author Jan Pesek, jan.pesek@wultra.com
+ */
+@SpringBootTest(
+ classes = EnrollmentServerTestApplication.class,
+ properties = {
+ "enrollment-server-onboarding.provider.innovatrics.serviceBaseUrl=http://localhost:" + InnovatricsRestApiServiceTest.PORT
+ })
+@ActiveProfiles("test")
+class InnovatricsRestApiServiceTest {
+
+ static final int PORT = 52936;
+
+ @Autowired
+ private InnovatricsApiService tested;
+
+ private MockWebServer mockWebServer;
+
+ @BeforeEach
+ void setup() throws Exception {
+ mockWebServer = new MockWebServer();
+ mockWebServer.start(PORT);
+ }
+
+ @AfterEach
+ void cleanup() throws Exception {
+ mockWebServer.shutdown();
+ }
+
+ @Test
+ void testCreateCustomer() throws Exception {
+ final OwnerId ownerId = createOwnerId();
+ mockWebServer.enqueue(new MockResponse()
+ .setHeader("Content-Type", MediaType.APPLICATION_JSON)
+ // Real response to POST /api/v1/customers
+ .setBody("""
+ {
+ "id": "c2e91b1f-0ccb-4ba0-93ae-d255a2a443af",
+ "links": {
+ "self": "/api/v1/customers/c2e91b1f-0ccb-4ba0-93ae-d255a2a443af"
+ }
+ }
+ """)
+ .setResponseCode(HttpStatus.OK.value()));
+
+ final CreateCustomerResponse response = tested.createCustomer(ownerId);
+ assertEquals("c2e91b1f-0ccb-4ba0-93ae-d255a2a443af", response.getId());
+ assertEquals("/api/v1/customers/c2e91b1f-0ccb-4ba0-93ae-d255a2a443af", response.getLinks().getSelf());
+ }
+
+ @Test
+ void testErrorResponse() {
+ final OwnerId ownerId = createOwnerId();
+ mockWebServer.enqueue(new MockResponse()
+ .setHeader("Content-Type", MediaType.APPLICATION_JSON)
+ // Real response to uploading a page without previous document resource creation
+ .setBody("""
+ {
+ "errorCode": "NOT_FOUND",
+ "errorMessage": "string"
+ }
+ """)
+ .setResponseCode(500));
+
+ assertThrows(RemoteCommunicationException.class, () -> tested.createCustomer(ownerId));
+ }
+
+ @Test
+ void testNonMatchingPageType() throws Exception {
+ final OwnerId ownerId = createOwnerId();
+ mockWebServer.enqueue(new MockResponse()
+ .setHeader("Content-Type", MediaType.APPLICATION_JSON)
+ // Real response to uploading a second page that is different from the first one
+ .setBody("""
+ {
+ "errorCode": "PAGE_DOESNT_MATCH_DOCUMENT_TYPE_OF_PREVIOUS_PAGE"
+ }
+ """)
+ .setResponseCode(HttpStatus.OK.value()));
+
+ final CreateDocumentPageResponse response = tested.provideDocumentPage("123", CardSide.FRONT, "data".getBytes(), ownerId);
+ assertNotNull(response.getErrorCode());
+
+ final RecordedRequest recordedRequest = mockWebServer.takeRequest(1L, TimeUnit.SECONDS);
+ assertNotNull(recordedRequest);
+ assertEquals("PUT /api/v1/customers/123/document/pages HTTP/1.1", recordedRequest.getRequestLine());
+ }
+
+ private OwnerId createOwnerId() {
+ final OwnerId ownerId = new OwnerId();
+ ownerId.setUserId("joe");
+ ownerId.setActivationId("a123");
+ return ownerId;
+ }
+
+}
diff --git a/enrollment-server-onboarding-provider-innovatrics/src/test/resources/application-test.properties b/enrollment-server-onboarding-provider-innovatrics/src/test/resources/application-test.properties
new file mode 100644
index 000000000..8e68187cc
--- /dev/null
+++ b/enrollment-server-onboarding-provider-innovatrics/src/test/resources/application-test.properties
@@ -0,0 +1,8 @@
+enrollment-server-onboarding.document-verification.provider=innovatrics
+enrollment-server-onboarding.presence-check.provider=innovatrics
+enrollment-server-onboarding.onboarding-process.enabled=false
+
+enrollment-server-onboarding.provider.innovatrics.documentVerificationConfiguration.documentCountries=CZE,SVK
+enrollment-server-onboarding.provider.innovatrics.document-verification-configuration.crucialFields=documentNumber, dateOfIssue, dateOfExpiry, surname, dateOfBirth, personalNumber, givenNames
+
+enrollment-server-onboarding.provider.innovatrics.restClientConfig.maxInMemorySize=1048576
\ No newline at end of file
diff --git a/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovPresenceCheckProvider.java b/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovPresenceCheckProvider.java
index 8e85e5f66..d34579224 100644
--- a/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovPresenceCheckProvider.java
+++ b/enrollment-server-onboarding-provider-iproov/src/main/java/com/wultra/app/onboardingserver/provider/iproov/IProovPresenceCheckProvider.java
@@ -127,8 +127,8 @@ public void initPresenceCheck(final OwnerId id, final Image photo) throws Presen
}
@Override
- public boolean shouldProvideTrustedPhoto() {
- return true;
+ public TrustedPhotoSource trustedPhotoSource() {
+ return TrustedPhotoSource.IMAGE;
}
@Override
diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProvider.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProvider.java
index bddd9e43c..692830985 100644
--- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProvider.java
+++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProvider.java
@@ -104,7 +104,11 @@ public DocumentsSubmitResult checkDocumentUpload(OwnerId id, DocumentVerificatio
}
@Override
- public DocumentsSubmitResult submitDocuments(OwnerId id, List documents) {
+ public DocumentsSubmitResult submitDocuments(OwnerId id, List documents) throws DocumentVerificationException {
+ if (documents.stream().anyMatch(doc -> "throw.exception".equals(doc.getPhoto().getFilename()))) {
+ throw new DocumentVerificationException("Filename to throw an exception is present in documents.");
+ }
+
final List submitResults = documents.stream()
.map(this::toDocumentSubmitResult)
.toList();
diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckService.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckService.java
index 86323a35f..12471f923 100644
--- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckService.java
+++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckService.java
@@ -44,10 +44,7 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
-import java.util.Date;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
+import java.util.*;
import static com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase.PRESENCE_CHECK;
import static com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus.*;
@@ -62,9 +59,6 @@
@Slf4j
public class PresenceCheckService {
- private static final String SESSION_ATTRIBUTE_TIMESTAMP_LAST_USED = "timestampLastUsed";
- private static final String SESSION_ATTRIBUTE_IMAGE_UPLOADED = "imageUploaded";
-
private final IdentityVerificationConfig identityVerificationConfig;
private final DocumentVerificationRepository documentVerificationRepository;
private final DocumentProcessingService documentProcessingService;
@@ -128,7 +122,7 @@ public void checkPresenceVerification(
final OwnerId ownerId,
final IdentityVerificationEntity idVerification) throws PresenceCheckException, RemoteCommunicationException {
- final SessionInfo sessionInfo = updateSessionInfo(ownerId, idVerification, Map.of(SESSION_ATTRIBUTE_TIMESTAMP_LAST_USED, ownerId.getTimestamp()));
+ final SessionInfo sessionInfo = updateSessionInfo(ownerId, idVerification, Map.of(SessionInfo.ATTRIBUTE_TIMESTAMP_LAST_USED, ownerId.getTimestamp()));
final PresenceCheckResult result = presenceCheckProvider.getResult(ownerId, sessionInfo);
auditService.auditPresenceCheckProvider(idVerification, "Got presence check result: {} for user: {}", result.getStatus(), ownerId.getUserId());
@@ -216,15 +210,16 @@ private void initPresentCheckWithImage(final OwnerId ownerId, final IdentityVeri
}
final Optional photo = fetchTrustedPhoto(ownerId, idVerification);
+ setIdentityDocumentReferences(ownerId, idVerification);
presenceCheckProvider.initPresenceCheck(ownerId, photo.orElse(null));
logger.info("Presence check initialized, {}", ownerId);
- updateSessionInfo(ownerId, idVerification, Map.of(SESSION_ATTRIBUTE_IMAGE_UPLOADED, true));
+ updateSessionInfo(ownerId, idVerification, Map.of(SessionInfo.ATTRIBUTE_IMAGE_UPLOADED, true));
auditService.auditPresenceCheckProvider(idVerification, "Presence check initialized for user: {}", ownerId.getUserId());
}
}
private Optional fetchTrustedPhoto(final OwnerId ownerId, final IdentityVerificationEntity idVerification) throws DocumentVerificationException, RemoteCommunicationException, PresenceCheckException {
- if (presenceCheckProvider.shouldProvideTrustedPhoto()) {
+ if (PresenceCheckProvider.TrustedPhotoSource.IMAGE == presenceCheckProvider.trustedPhotoSource()) {
final Image photo = fetchTrustedPhotoFromDocumentVerifier(ownerId, idVerification);
final Image upscaledPhoto = imageProcessor.upscaleImage(ownerId, photo, identityVerificationConfig.getMinimalSelfieWidth());
return Optional.of(upscaledPhoto);
@@ -233,6 +228,22 @@ private Optional fetchTrustedPhoto(final OwnerId ownerId, final IdentityV
}
}
+ private void setIdentityDocumentReferences(final OwnerId ownerId, final IdentityVerificationEntity idVerification) throws DocumentVerificationException, PresenceCheckException {
+ if (PresenceCheckProvider.TrustedPhotoSource.REFERENCE != presenceCheckProvider.trustedPhotoSource()) {
+ return;
+ }
+
+ final List docsWithPhoto = getDocsWithPhoto(idVerification, ownerId);
+ final String primaryDocReference = getPreferredDocWithPhoto(docsWithPhoto, ownerId).getPhotoId();
+ final List otherDocsReferences = docsWithPhoto.stream()
+ .map(DocumentVerificationEntity::getPhotoId)
+ .filter(id -> !Objects.equals(id, primaryDocReference))
+ .distinct().toList();
+
+ updateSessionInfo(ownerId, idVerification, Map.of(SessionInfo.ATTRIBUTE_PRIMARY_DOCUMENT_REFERENCE, primaryDocReference,
+ SessionInfo.ATTRIBUTE_OTHER_DOCUMENTS_REFERENCES, otherDocsReferences));
+ }
+
/**
* Starts new presence check process.
*
@@ -264,6 +275,15 @@ private SessionInfo startPresenceCheck(OwnerId ownerId, IdentityVerificationEnti
protected Image fetchTrustedPhotoFromDocumentVerifier(final OwnerId ownerId, final IdentityVerificationEntity idVerification)
throws DocumentVerificationException, RemoteCommunicationException {
+ final List docsWithPhoto = getDocsWithPhoto(idVerification, ownerId);
+ final DocumentVerificationEntity preferredDocWithPhoto = getPreferredDocWithPhoto(docsWithPhoto, ownerId);
+ logger.info("Selected {} as the source of person photo, {}", preferredDocWithPhoto, ownerId);
+ final String photoId = preferredDocWithPhoto.getPhotoId();
+ return identityVerificationService.getPhotoById(photoId, ownerId);
+ }
+
+ private List getDocsWithPhoto(final IdentityVerificationEntity idVerification,
+ final OwnerId ownerId) throws DocumentVerificationException {
final List docsWithPhoto = documentVerificationRepository.findAllWithPhoto(idVerification);
if (docsWithPhoto.isEmpty()) {
throw new DocumentVerificationException("Unable to initialize presence check - missing person photo, " + ownerId);
@@ -273,23 +293,23 @@ protected Image fetchTrustedPhotoFromDocumentVerifier(final OwnerId ownerId, fin
Preconditions.checkNotNull(docWithPhoto.getPhotoId(), "Expected photoId value in " + docWithPhoto)
);
- DocumentVerificationEntity preferredDocWithPhoto = null;
+ return docsWithPhoto;
+ }
+
+ private DocumentVerificationEntity getPreferredDocWithPhoto(final List docsWithPhoto,
+ final OwnerId ownerId) {
+
for (DocumentType documentType : DocumentType.PREFERRED_SOURCE_OF_PERSON_PHOTO) {
Optional docEntity = docsWithPhoto.stream()
.filter(value -> value.getType() == documentType)
.findFirst();
if (docEntity.isPresent()) {
- preferredDocWithPhoto = docEntity.get();
- break;
+ return docEntity.get();
}
}
- if (preferredDocWithPhoto == null) {
- logger.warn("Unable to select a source of person photo to initialize presence check, {}", ownerId);
- preferredDocWithPhoto = docsWithPhoto.get(0);
- }
- logger.info("Selected {} as the source of person photo, {}", preferredDocWithPhoto, ownerId);
- String photoId = preferredDocWithPhoto.getPhotoId();
- return identityVerificationService.getPhotoById(photoId, ownerId);
+
+ logger.warn("Unable to select a source of person photo to initialize presence check, {}", ownerId);
+ return docsWithPhoto.get(0);
}
private void evaluatePresenceCheckResult(OwnerId ownerId,
@@ -389,6 +409,6 @@ private boolean imageAlreadyUploaded(final IdentityVerificationEntity identityVe
final SessionInfo sessionInfo = jsonSerializationService.deserialize(sessionInfoString, SessionInfo.class);
return sessionInfo != null
&& !CollectionUtils.isEmpty(sessionInfo.getSessionAttributes())
- && Boolean.TRUE.equals(sessionInfo.getSessionAttributes().get(SESSION_ATTRIBUTE_IMAGE_UPLOADED));
+ && Boolean.TRUE.equals(sessionInfo.getSessionAttributes().get(SessionInfo.ATTRIBUTE_IMAGE_UPLOADED));
}
}
diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingService.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingService.java
index 948919729..7dae9452b 100644
--- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingService.java
+++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingService.java
@@ -17,6 +17,7 @@
*/
package com.wultra.app.onboardingserver.impl.service.document;
+import com.google.common.base.Strings;
import com.wultra.app.enrollmentserver.api.model.onboarding.request.DocumentSubmitRequest;
import com.wultra.app.enrollmentserver.model.Document;
import com.wultra.app.enrollmentserver.model.DocumentMetadata;
@@ -43,10 +44,9 @@
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
+import java.util.*;
+
+import static java.util.stream.Collectors.groupingBy;
/**
* Service implementing document processing features.
@@ -119,22 +119,44 @@ public List submitDocuments(
DocumentSubmitRequest request,
OwnerId ownerId) throws DocumentSubmitException {
- List documents = getDocuments(ownerId, request);
-
- List docVerifications = new ArrayList<>();
- List docResults = new ArrayList<>();
+ checkDocumentResubmit(ownerId, request);
+ final List documents = getDocuments(ownerId, request);
+ final var documentsByType = request.getDocuments().stream()
+ .collect(groupingBy(DocumentSubmitRequest.DocumentMetadata::getType));
- List docsMetadata = request.getDocuments();
- for (DocumentSubmitRequest.DocumentMetadata docMetadata : docsMetadata) {
- DocumentVerificationEntity docVerification = createDocumentVerification(ownerId, idVerification, docMetadata);
- docVerification.setIdentityVerification(idVerification);
- docVerifications.add(docVerification);
+ final List docVerifications = new ArrayList<>();
+ for (var docMetadataList : documentsByType.values()) {
+ docVerifications.addAll(submitDocument(docMetadataList, documents, idVerification, ownerId));
+ }
+ return docVerifications;
+ }
- checkDocumentResubmit(ownerId, request, docVerification);
+ /**
+ * Submit pages of a document to document verify provider.
+ * @param pagesMetadata Pages metadata from request.
+ * @param pagesData Pages data.
+ * @param idVerification Identity verification entity.
+ * @param ownerId Owner identification.
+ * @return
+ */
+ private List submitDocument(final List pagesMetadata,
+ final List pagesData,
+ final IdentityVerificationEntity idVerification,
+ final OwnerId ownerId) {
+
+ // Maps are used to associate DocumentsSubmitResult - DocumentVerificationEntities - DocumentMetadata
+ final Map docVerifications = new HashMap<>();
+ final Map docMetadataMap = new HashMap<>();
+
+ final List submittedDocuments = new ArrayList<>();
+ for (var metadata : pagesMetadata) {
+ final DocumentVerificationEntity docVerification = createDocumentVerification(ownerId, idVerification, metadata);
+ docVerifications.put(docVerification.getId(), docVerification);
+ docMetadataMap.put(docVerification.getId(), metadata);
+ handleResubmit(ownerId, metadata.getOriginalDocumentId(), docVerification);
- SubmittedDocument submittedDoc;
try {
- submittedDoc = createSubmittedDocument(ownerId, docMetadata, documents);
+ submittedDocuments.add(createSubmittedDocument(ownerId, metadata, pagesData, docVerification));
} catch (DocumentSubmitException e) {
logger.warn("Document verification ID: {}, failed: {}", docVerification.getId(), e.getMessage());
logger.debug("Document verification ID: {}, failed", docVerification.getId(), e);
@@ -142,60 +164,98 @@ public List submitDocuments(
docVerification.setErrorDetail(ErrorDetail.DOCUMENT_VERIFICATION_FAILED);
docVerification.setErrorOrigin(ErrorOrigin.DOCUMENT_VERIFICATION);
auditService.audit(docVerification, "Document verification failed for user: {}", ownerId.getUserId());
- return docVerifications;
+ return docVerifications.values().stream().toList();
}
+ }
- DocumentSubmitResult docSubmitResult = submitDocumentToProvider(ownerId, docVerification, submittedDoc);
+ final List docVerificationsList = docVerifications.values().stream().toList();
+ final DocumentsSubmitResult results = submitDocumentToProvider(submittedDocuments, docVerificationsList, idVerification, ownerId);
+ processSubmitResults(results, docVerifications, ownerId);
- // TODO - after synchronous submission to document verification provider the document state should be
- // set to VERIFICATION_PENDING, for asynchronous processing the UPLOAD_IN_PROGRESS state should remain
+ docVerificationsList.stream()
+ .filter(doc -> StringUtils.isNotBlank(doc.getUploadId()))
+ .map(doc -> docMetadataMap.get(doc.getId()).getUploadId())
+ .filter(StringUtils::isNotBlank)
+ .forEach(fileUploadId -> {
+ documentDataRepository.deleteById(fileUploadId);
+ logger.info("Deleted stored document data with id={}, {}", fileUploadId, ownerId);
+ });
- DocumentResultEntity docResult = createDocumentResult(docVerification, docSubmitResult);
- docResult.setTimestampCreated(ownerId.getTimestamp());
+ return docVerificationsList;
+ }
- docResults.add(docResult);
+ /**
+ * Process submit results.
+ * @param results Document submit result from provider.
+ * @param docVerificationsMap To pair results with corresponding DocumentVerificationEntity.
+ * @param ownerId Owner identification.
+ */
+ private void processSubmitResults(final DocumentsSubmitResult results,
+ final Map docVerificationsMap,
+ final OwnerId ownerId) {
- // Delete previously persisted document after a successful upload to the provider
- if (docVerification.getUploadId() != null && docMetadata.getUploadId() != null) {
- documentDataRepository.deleteById(docMetadata.getUploadId());
- }
- }
+ final List docResults = new ArrayList<>();
- documentVerificationRepository.saveAll(docVerifications);
+ for (final DocumentSubmitResult result : results.getResults()) {
+ final DocumentVerificationEntity docVerification = docVerificationsMap.get(result.getDocumentId());
+ processDocsSubmitResults(ownerId, docVerification, results, result);
- for (int i = 0; i < docVerifications.size(); i++) {
- DocumentVerificationEntity docVerificationEntity = docVerifications.get(i);
- docResults.get(i).setDocumentVerification(docVerificationEntity);
+ final DocumentResultEntity docResult = createDocumentResult(docVerification, result);
+ docResult.setTimestampCreated(ownerId.getTimestamp());
+ docResult.setDocumentVerification(docVerification);
+
+ docResults.add(docResult);
}
- documentResultRepository.saveAll(docResults);
- return docVerifications;
+ documentVerificationRepository.saveAll(docVerificationsMap.values());
+ documentResultRepository.saveAll(docResults);
+ logger.debug("Processed submit result of documents {}, {}", docVerificationsMap.values(), ownerId);
}
- public void checkDocumentResubmit(OwnerId ownerId,
- DocumentSubmitRequest request,
- DocumentVerificationEntity docVerification) throws DocumentSubmitException {
- if (request.isResubmit() && docVerification.getOriginalDocumentId() == null) {
- throw new DocumentSubmitException(
- String.format("Detected a resubmit request without specified originalDocumentId for %s, %s", docVerification, ownerId));
- } else if (request.isResubmit()) {
- Optional originalDocOptional =
- documentVerificationRepository.findById(docVerification.getOriginalDocumentId());
- if (originalDocOptional.isEmpty()) {
- logger.warn("Missing previous DocumentVerificationEntity(originalDocumentId={}), {}",
- docVerification.getOriginalDocumentId(), ownerId);
- } else {
- DocumentVerificationEntity originalDoc = originalDocOptional.get();
- originalDoc.setStatus(DocumentStatus.DISPOSED);
- originalDoc.setUsedForVerification(false);
- originalDoc.setTimestampDisposed(ownerId.getTimestamp());
- originalDoc.setTimestampLastUpdated(ownerId.getTimestamp());
- logger.info("Replaced previous {} with new {}, {}", originalDocOptional, docVerification, ownerId);
- auditService.audit(docVerification, "Document replaced with new one for user: {}", ownerId.getUserId());
+ /**
+ * Validates resubmit parameters of DocumentSubmitRequest.
+ * @param ownerId Owner identification.
+ * @param request Request body.
+ * @throws DocumentSubmitException If request is resubmit without original document ID, or is not resubmit with original document ID
+ */
+ private void checkDocumentResubmit(final OwnerId ownerId, final DocumentSubmitRequest request) throws DocumentSubmitException {
+ final boolean isResubmit = request.isResubmit();
+ for (var metadata : request.getDocuments()) {
+ final String originalDocumentId = metadata.getOriginalDocumentId();
+ if (isResubmit && StringUtils.isBlank(originalDocumentId)) {
+ logger.debug("Request has resubmit flag but misses originalDocumentId {}, {}", metadata, ownerId);
+ throw new DocumentSubmitException("Detected a resubmit request without specified originalDocumentId, %s".formatted(ownerId));
+ } else if (!isResubmit && StringUtils.isNotBlank(originalDocumentId)) {
+ logger.debug("Request has originalDocumentId but is not flagged as resubmit {}, {}", metadata, ownerId);
+ throw new DocumentSubmitException("Detected a submit request with specified originalDocumentId=%s, %s".formatted(originalDocumentId, ownerId));
}
- } else if (!request.isResubmit() && docVerification.getOriginalDocumentId() != null) {
- throw new DocumentSubmitException(
- String.format("Detected a submit request with specified originalDocumentId=%s for %s, %s", docVerification.getOriginalDocumentId(), docVerification, ownerId));
+ }
+ }
+
+ /**
+ * Sets document with originalDocumentId as disposed. If passed originalDocumentId does not exist, no further action is taken.
+ * @param ownerId Owner identification.
+ * @param originalDocumentId Id of the original document.
+ * @param docVerification Resubmitted document.
+ */
+ private void handleResubmit(final OwnerId ownerId, final String originalDocumentId, final DocumentVerificationEntity docVerification) {
+ if (Strings.isNullOrEmpty(originalDocumentId)) {
+ logger.debug("Document {} is not a resubmit {}", docVerification, ownerId);
+ return;
+ }
+
+ logger.debug("Document {} is a resubmit, {}", docVerification, ownerId);
+ final Optional originalDocOptional = documentVerificationRepository.findById(originalDocumentId);
+ if (originalDocOptional.isEmpty()) {
+ logger.warn("Missing previous DocumentVerificationEntity(originalDocumentId={}), {}", originalDocumentId, ownerId);
+ } else {
+ final DocumentVerificationEntity originalDoc = originalDocOptional.get();
+ originalDoc.setStatus(DocumentStatus.DISPOSED);
+ originalDoc.setUsedForVerification(false);
+ originalDoc.setTimestampDisposed(ownerId.getTimestamp());
+ originalDoc.setTimestampLastUpdated(ownerId.getTimestamp());
+ logger.info("Replaced previous {} with new {}, {}", originalDoc, docVerification, ownerId);
+ auditService.audit(docVerification, "Document replaced with new one for user: {}", ownerId.getUserId());
}
}
@@ -243,6 +303,41 @@ public void checkDocumentSubmitWithProvider(OwnerId ownerId, DocumentResultEntit
processDocsSubmitResults(ownerId, docVerification, docsSubmitResults, docSubmitResult);
}
+ /**
+ * Pass all pages of a document to document verification provider at a single call.
+ * @param submittedDocs Document pages to submit.
+ * @param docVerifications Entities associated with the document pages to submit.
+ * @param identityVerification Identity verification entity.
+ * @param ownerId Owner identification.
+ * @return document submit result
+ */
+ private DocumentsSubmitResult submitDocumentToProvider(final List submittedDocs,
+ final List docVerifications,
+ final IdentityVerificationEntity identityVerification,
+ final OwnerId ownerId) {
+
+ final List docVerificationIds = docVerifications.stream().map(DocumentVerificationEntity::getId).toList();
+
+ try {
+ final DocumentsSubmitResult results = documentVerificationProvider.submitDocuments(ownerId, submittedDocs);
+ logger.debug("Documents {} submitted to provider, {}", docVerifications, ownerId);
+ auditService.auditDocumentVerificationProvider(identityVerification, "Submit documents for user: {}, document IDs: {}", ownerId.getUserId(), docVerificationIds);
+ return results;
+ } catch (DocumentVerificationException | RemoteCommunicationException e) {
+ logger.warn("Document verification ID: {}, failed: {}", docVerificationIds, e.getMessage());
+ logger.debug("Document verification ID: {}, failed", docVerificationIds, e);
+ final DocumentsSubmitResult results = new DocumentsSubmitResult();
+ submittedDocs.forEach(doc -> {
+ final DocumentSubmitResult result = new DocumentSubmitResult();
+ result.setDocumentId(doc.getDocumentId());
+ result.setErrorDetail(e.getMessage());
+ results.getResults().add(result);
+ });
+ return results;
+ }
+
+ }
+
public DocumentSubmitResult submitDocumentToProvider(OwnerId ownerId, DocumentVerificationEntity docVerification, SubmittedDocument submittedDoc) {
DocumentsSubmitResult docsSubmitResults;
DocumentSubmitResult docSubmitResult;
@@ -293,8 +388,8 @@ public void pairTwoSidedDocuments(List documents) {
continue;
}
documents.stream()
- .filter(item -> item.getType().equals(document.getType()))
- .filter(item -> !item.getSide().equals(document.getSide()))
+ .filter(item -> item.getType() == document.getType())
+ .filter(item -> item.getSide() != document.getSide())
.forEach(item -> {
logger.debug("Found other side {} for {}", item, document);
item.setOtherSideId(document.getId());
@@ -353,6 +448,7 @@ private DocumentVerificationEntity createDocumentVerification(OwnerId ownerId, I
entity.setTimestampCreated(ownerId.getTimestamp());
entity.setUsedForVerification(true);
final DocumentVerificationEntity saveEntity = documentVerificationRepository.save(entity);
+ logger.debug("Created {} for {}", saveEntity, ownerId);
auditService.auditDebug(entity, "Document created for user: {}", ownerId.getUserId());
return saveEntity;
}
@@ -360,13 +456,14 @@ private DocumentVerificationEntity createDocumentVerification(OwnerId ownerId, I
private SubmittedDocument createSubmittedDocument(
OwnerId ownerId,
DocumentSubmitRequest.DocumentMetadata docMetadata,
- List docs) throws DocumentSubmitException {
+ List docs,
+ DocumentVerificationEntity docVerification) throws DocumentSubmitException {
final Image photo = Image.builder()
.filename(docMetadata.getFilename())
.build();
SubmittedDocument submittedDoc = new SubmittedDocument();
- submittedDoc.setDocumentId(docMetadata.getUploadId());
+ submittedDoc.setDocumentId(docVerification.getId());
submittedDoc.setPhoto(photo);
submittedDoc.setSide(docMetadata.getSide());
submittedDoc.setType(docMetadata.getType());
diff --git a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java
index a42241794..4be8ac2f5 100644
--- a/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java
+++ b/enrollment-server-onboarding/src/main/java/com/wultra/app/onboardingserver/presencecheck/mock/provider/WultraMockPresenceCheckProvider.java
@@ -62,8 +62,8 @@ public void initPresenceCheck(OwnerId id, Image photo) {
}
@Override
- public boolean shouldProvideTrustedPhoto() {
- return true;
+ public TrustedPhotoSource trustedPhotoSource() {
+ return TrustedPhotoSource.IMAGE;
}
@Override
diff --git a/enrollment-server-onboarding/src/main/resources/application.properties b/enrollment-server-onboarding/src/main/resources/application.properties
index a21dcc03f..9b608a5d4 100644
--- a/enrollment-server-onboarding/src/main/resources/application.properties
+++ b/enrollment-server-onboarding/src/main/resources/application.properties
@@ -173,7 +173,11 @@ enrollment-server-onboarding.provider.innovatrics.serviceToken=${INNOVATRICS_SER
enrollment-server-onboarding.provider.innovatrics.serviceUserAgent=Wultra/OnboardingServer
# Innovatrics presence-check configuration
-enrollment-server-onboarding.provider.innovatrics.presenceCheck.score=0.875
+enrollment-server-onboarding.provider.innovatrics.presenceCheckConfiguration.score=0.875
+
+# Innovatrics document-verification configuration
+enrollment-server-onboarding.provider.innovatrics.documentVerificationConfiguration.documentCountries=CZE
+enrollment-server-onboarding.provider.innovatrics.document-verification-configuration.crucialFields=documentNumber, dateOfIssue, dateOfExpiry, surname, dateOfBirth, personalNumber, givenNames
# Innovatrics REST client configuration
enrollment-server-onboarding.provider.innovatrics.restClientConfig.acceptInvalidSslCertificate=false
diff --git a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java
index 721d72e80..bad60c5bd 100644
--- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java
+++ b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/docverify/mock/provider/WultraMockDocumentVerificationProviderTest.java
@@ -65,7 +65,7 @@ public void setProvider(WultraMockDocumentVerificationProvider provider) {
}
@Test
- void checkDocumentUploadTest() {
+ void checkDocumentUploadTest() throws Exception {
SubmittedDocument document = createSubmittedDocument();
DocumentsSubmitResult submitResult = provider.submitDocuments(ownerId, List.of(document));
diff --git a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckServiceTest.java b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckServiceTest.java
index c5daedb85..c256c6d5d 100644
--- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckServiceTest.java
+++ b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/PresenceCheckServiceTest.java
@@ -16,20 +16,39 @@
*/
package com.wultra.app.onboardingserver.impl.service;
+import com.wultra.app.enrollmentserver.model.enumeration.CardSide;
import com.wultra.app.enrollmentserver.model.enumeration.DocumentType;
+import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase;
+import com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus;
import com.wultra.app.enrollmentserver.model.integration.Image;
import com.wultra.app.enrollmentserver.model.integration.OwnerId;
+import com.wultra.app.enrollmentserver.model.integration.SessionInfo;
+import com.wultra.app.onboardingserver.EnrollmentServerTestApplication;
+import com.wultra.app.onboardingserver.api.provider.PresenceCheckProvider;
import com.wultra.app.onboardingserver.common.database.DocumentVerificationRepository;
+import com.wultra.app.onboardingserver.common.database.IdentityVerificationRepository;
+import com.wultra.app.onboardingserver.common.database.ScaResultRepository;
import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity;
import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity;
+import com.wultra.app.onboardingserver.impl.service.internal.JsonSerializationService;
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 org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.ActiveProfiles;
import java.util.List;
+import java.util.Optional;
+import static com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase.DOCUMENT_VERIFICATION_FINAL;
+import static com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationPhase.PRESENCE_CHECK;
+import static com.wultra.app.enrollmentserver.model.enumeration.IdentityVerificationStatus.*;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.*;
/**
@@ -38,16 +57,23 @@
* @author Lukas Lukovsky, lukas.lukovsky@wultra.com
* @author Lubos Racansky, lubos.racansky@wultra.com
*/
-@ExtendWith(MockitoExtension.class)
+@SpringBootTest(classes = EnrollmentServerTestApplication.class)
+@ActiveProfiles("test")
class PresenceCheckServiceTest {
- @Mock
+ @MockBean
private IdentityVerificationService identityVerificationService;
- @Mock
+ @MockBean
private DocumentVerificationRepository documentVerificationRepository;
- @InjectMocks
+ @MockBean
+ private PresenceCheckLimitService presenceCheckLimitService;
+
+ @MockBean
+ private PresenceCheckProvider presenceCheckProvider;
+
+ @Autowired
private PresenceCheckService tested;
@Test
@@ -94,4 +120,44 @@ void testFetchTrustedPhotoFromDocumentVerifier_unknownDocument() throws Exceptio
verify(identityVerificationService, times(1)).getPhotoById(docPhotoUnknown.getPhotoId(), ownerId);
}
+ @Test
+ void initPresentCheckWithImage_withDocumentReferences() throws Exception {
+ final OwnerId ownerId = new OwnerId();
+ ownerId.setActivationId("a1");
+
+ final DocumentVerificationEntity page1 = new DocumentVerificationEntity();
+ page1.setId("1");
+ page1.setType(DocumentType.ID_CARD);
+ page1.setSide(CardSide.FRONT);
+ page1.setPhotoId("id_card_portrait");
+
+ final DocumentVerificationEntity page2 = new DocumentVerificationEntity();
+ page2.setId("2");
+ page2.setType(DocumentType.ID_CARD);
+ page2.setSide(CardSide.BACK);
+ page2.setPhotoId("id_card_portrait");
+
+ final DocumentVerificationEntity page3 = new DocumentVerificationEntity();
+ page3.setId("3");
+ page3.setType(DocumentType.DRIVING_LICENSE);
+ page3.setSide(CardSide.FRONT);
+ page3.setPhotoId("driving_licence_portrait");
+
+ when(presenceCheckProvider.trustedPhotoSource()).thenReturn(PresenceCheckProvider.TrustedPhotoSource.REFERENCE);
+
+ final IdentityVerificationEntity identityVerification = new IdentityVerificationEntity();
+ identityVerification.setPhase(PRESENCE_CHECK);
+ identityVerification.setStatus(NOT_INITIALIZED);
+
+ when(documentVerificationRepository.findAllWithPhoto(identityVerification)).thenReturn(List.of(page1, page2, page3));
+ when(identityVerificationService.findByOptional(ownerId)).thenReturn(Optional.of(identityVerification));
+ when(presenceCheckProvider.startPresenceCheck(ownerId)).thenReturn(new SessionInfo());
+
+ tested.init(ownerId, "p1");
+
+ assertTrue(identityVerification.getSessionInfo().contains("\"primaryDocumentReference\":\"id_card_portrait\""));
+ assertTrue(identityVerification.getSessionInfo().contains("\"otherDocumentsReferences\":[\"driving_licence_portrait\"]"));
+ verify(presenceCheckProvider).initPresenceCheck(ownerId, null);
+ }
+
}
diff --git a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.java b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.java
index 92fd2aebf..37309ec05 100644
--- a/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.java
+++ b/enrollment-server-onboarding/src/test/java/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.java
@@ -17,58 +17,274 @@
*/
package com.wultra.app.onboardingserver.impl.service.document;
+import com.wultra.app.enrollmentserver.api.model.onboarding.request.DocumentSubmitRequest;
+import com.wultra.app.enrollmentserver.model.Document;
+import com.wultra.app.enrollmentserver.model.enumeration.*;
+import com.wultra.app.enrollmentserver.model.integration.*;
+import com.wultra.app.onboardingserver.EnrollmentServerTestApplication;
+import com.wultra.app.onboardingserver.common.database.DocumentResultRepository;
import com.wultra.app.onboardingserver.common.database.DocumentVerificationRepository;
+import com.wultra.app.onboardingserver.common.database.IdentityVerificationRepository;
+import com.wultra.app.onboardingserver.common.database.entity.DocumentResultEntity;
import com.wultra.app.onboardingserver.common.database.entity.DocumentVerificationEntity;
-import com.wultra.app.enrollmentserver.model.enumeration.CardSide;
-import com.wultra.app.enrollmentserver.model.enumeration.DocumentType;
-import org.junit.jupiter.api.BeforeEach;
+import com.wultra.app.onboardingserver.common.database.entity.IdentityVerificationEntity;
+import com.wultra.app.onboardingserver.errorhandling.DocumentSubmitException;
+import com.wultra.app.onboardingserver.impl.service.DataExtractionService;
import org.junit.jupiter.api.Test;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.jdbc.Sql;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
-import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* @author Lukas Lukovsky, lukas.lukovsky@wultra.com
+ * @author Jan Pesek, jan.pesek@wultra.com
*/
+@SpringBootTest(classes = EnrollmentServerTestApplication.class)
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
+@ActiveProfiles("test")
+@Sql
class DocumentProcessingServiceTest {
- @InjectMocks
- DocumentProcessingService service;
+ @MockBean
+ DataExtractionService dataExtractionService;
- @Mock
+ @Autowired
+ DocumentProcessingService tested;
+
+ @Autowired
DocumentVerificationRepository documentVerificationRepository;
- @BeforeEach
- public void init() {
- MockitoAnnotations.openMocks(this);
+ @Autowired
+ IdentityVerificationRepository identityVerificationRepository;
+
+ @Autowired
+ DocumentResultRepository documentResultRepository;
+
+ @Test
+ @Sql
+ void testPairTwoSidedDocuments() {
+ tested.pairTwoSidedDocuments(documentVerificationRepository.findAll());
+ assertEquals("2", documentVerificationRepository.findById("1").map(DocumentVerificationEntity::getOtherSideId).get());
+ assertEquals("1", documentVerificationRepository.findById("2").map(DocumentVerificationEntity::getOtherSideId).get());
+ }
+
+ @Test
+ void testSubmitDocuments() throws Exception {
+ final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get();
+ assertNotNull(identityVerification);
+
+ final List metadata = createIdCardMetadata();
+ final List data = createIdCardData();
+ final OwnerId ownerId = createOwnerId();
+
+ final DocumentSubmitRequest request = new DocumentSubmitRequest();
+ request.setProcessId("p1");
+ request.setResubmit(false);
+ request.setData("files".getBytes());
+ request.setDocuments(metadata);
+ when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data);
+
+ tested.submitDocuments(identityVerification, request, ownerId);
+
+ final List documents = documentVerificationRepository.findAll();
+ assertEquals(2, documents.size());
+ assertThat(documents)
+ .extracting(DocumentVerificationEntity::getSide)
+ .containsExactlyInAnyOrder(CardSide.FRONT, CardSide.BACK);
+ assertThat(documents)
+ .extracting(DocumentVerificationEntity::getStatus)
+ .containsOnly(DocumentStatus.VERIFICATION_PENDING);
+
+ final List results = new ArrayList<>();
+ documentResultRepository.findAll().forEach(results::add);
+ assertEquals(2, results.size());
+ assertThat(results)
+ .extracting(DocumentResultEntity::getDocumentVerification)
+ .extracting(DocumentVerificationEntity::getId)
+ .containsExactlyInAnyOrder(documents.stream().map(DocumentVerificationEntity::getId).toArray(String[]::new));
+ assertThat(results)
+ .extracting(DocumentResultEntity::getPhase)
+ .containsOnly(DocumentProcessingPhase.UPLOAD);
+ }
+
+ @Test
+ void testSubmitDocuments_providerThrows() throws Exception {
+ final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get();
+ assertNotNull(identityVerification);
+
+ final List metadata = createIdCardMetadata();
+ metadata.get(1).setFilename("throw.exception");
+ final List data = createIdCardData();
+ data.get(1).setFilename("throw.exception");
+ final OwnerId ownerId = createOwnerId();
+
+ final DocumentSubmitRequest request = new DocumentSubmitRequest();
+ request.setProcessId("p1");
+ request.setResubmit(false);
+ request.setData("files".getBytes());
+ request.setDocuments(metadata);
+ when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data);
+
+ tested.submitDocuments(identityVerification, request, ownerId);
+
+ final List documents = documentVerificationRepository.findAll();
+ assertEquals(2, documents.size());
+ assertThat(documents)
+ .extracting(DocumentVerificationEntity::getSide)
+ .containsExactlyInAnyOrder(CardSide.FRONT, CardSide.BACK);
+ assertThat(documents)
+ .extracting(DocumentVerificationEntity::getStatus)
+ .containsOnly(DocumentStatus.FAILED);
+
+ final List results = new ArrayList<>();
+ documentResultRepository.findAll().forEach(results::add);
+ assertEquals(2, results.size());
+ assertThat(results)
+ .extracting(DocumentResultEntity::getErrorDetail)
+ .containsOnly("documentVerificationFailed");
+ assertThat(results)
+ .extracting(DocumentResultEntity::getErrorOrigin)
+ .containsOnly(ErrorOrigin.DOCUMENT_VERIFICATION);
+ }
+
+ @Test
+ void testSubmitDocuments_missingData() throws Exception {
+ final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get();
+ assertNotNull(identityVerification);
+
+ final List metadata = createIdCardMetadata();
+ final List data = Collections.emptyList();
+ final OwnerId ownerId = createOwnerId();
+
+ final DocumentSubmitRequest request = new DocumentSubmitRequest();
+ request.setProcessId("p1");
+ request.setResubmit(false);
+ request.setData("files".getBytes());
+ request.setDocuments(metadata);
+ when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data);
+
+ tested.submitDocuments(identityVerification, request, ownerId);
+
+ List documents = documentVerificationRepository.findAll();
+ assertEquals(1, documents.size());
+ assertThat(documents)
+ .extracting(DocumentVerificationEntity::getStatus)
+ .containsExactlyInAnyOrder(DocumentStatus.FAILED);
+ }
+
+ @Test
+ @Sql
+ void testResubmitDocuments() throws Exception {
+ final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get();
+ assertNotNull(identityVerification);
+
+ final List metadata = createIdCardMetadata();
+ metadata.get(0).setOriginalDocumentId("original1");
+ metadata.get(1).setOriginalDocumentId("original2");
+ final List data = createIdCardData();
+ final OwnerId ownerId = createOwnerId();
+
+ final DocumentSubmitRequest request = new DocumentSubmitRequest();
+ request.setProcessId("p1");
+ request.setResubmit(true);
+ request.setData("files".getBytes());
+ request.setDocuments(metadata);
+ when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data);
+
+ tested.submitDocuments(identityVerification, request, ownerId);
+ List documents = documentVerificationRepository.findAll();
+ assertEquals(4, documents.size());
+ assertThat(documents)
+ .extracting(DocumentVerificationEntity::getStatus)
+ .containsOnly(DocumentStatus.VERIFICATION_PENDING, DocumentStatus.DISPOSED);
+ }
+
+ @Test
+ void testResubmitDocuments_missingOriginalDocumentId() throws Exception {
+ final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get();
+ assertNotNull(identityVerification);
+
+ final List metadata = createIdCardMetadata();
+ final List data = createIdCardData();
+ final OwnerId ownerId = createOwnerId();
+
+ final DocumentSubmitRequest request = new DocumentSubmitRequest();
+ request.setProcessId("p1");
+ request.setResubmit(true);
+ request.setData("files".getBytes());
+ request.setDocuments(metadata);
+ when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data);
+
+ final DocumentSubmitException exception = assertThrows(DocumentSubmitException.class,
+ () -> tested.submitDocuments(identityVerification, request, ownerId));
+ assertEquals("Detected a resubmit request without specified originalDocumentId, %s".formatted(ownerId), exception.getMessage());
}
@Test
- void pairTwoSidedDocumentsTest() {
- DocumentVerificationEntity docIdCardFront = new DocumentVerificationEntity();
- docIdCardFront.setId("1");
- docIdCardFront.setType(DocumentType.ID_CARD);
- docIdCardFront.setSide(CardSide.FRONT);
-
- DocumentVerificationEntity docIdCardBack = new DocumentVerificationEntity();
- docIdCardBack.setId("2");
- docIdCardBack.setType(DocumentType.ID_CARD);
- docIdCardBack.setSide(CardSide.BACK);
-
- List documents = List.of(docIdCardFront, docIdCardBack);
-
- service.pairTwoSidedDocuments(documents);
- when(documentVerificationRepository.setOtherDocumentSide("1", "2")).thenReturn(1);
- verify(documentVerificationRepository, times(1)).setOtherDocumentSide("1", "2");
- when(documentVerificationRepository.setOtherDocumentSide("2", "1")).thenReturn(1);
- verify(documentVerificationRepository, times(1)).setOtherDocumentSide("2", "1");
- assertEquals(docIdCardBack.getId(), docIdCardFront.getOtherSideId());
- assertEquals(docIdCardFront.getId(), docIdCardBack.getOtherSideId());
+ void testResubmitDocuments_missingResubmitFlag() throws Exception {
+ final IdentityVerificationEntity identityVerification = identityVerificationRepository.findById("v1").get();
+ assertNotNull(identityVerification);
+
+ final List metadata = createIdCardMetadata();
+ metadata.get(0).setOriginalDocumentId("original1");
+ metadata.get(1).setOriginalDocumentId("original2");
+ final List data = createIdCardData();
+ final OwnerId ownerId = createOwnerId();
+
+ final DocumentSubmitRequest request = new DocumentSubmitRequest();
+ request.setProcessId("p1");
+ request.setResubmit(false);
+ request.setData("files".getBytes());
+ request.setDocuments(metadata);
+ when(dataExtractionService.extractDocuments(request.getData())).thenReturn(data);
+
+ final DocumentSubmitException exception = assertThrows(DocumentSubmitException.class,
+ () -> tested.submitDocuments(identityVerification, request, ownerId));
+ assertEquals("Detected a submit request with specified originalDocumentId=original1, %s".formatted(ownerId), exception.getMessage());
+ }
+
+ private List createIdCardMetadata() {
+ final DocumentSubmitRequest.DocumentMetadata page1 = new DocumentSubmitRequest.DocumentMetadata();
+ page1.setFilename("id_card_front.png");
+ page1.setType(DocumentType.ID_CARD);
+ page1.setSide(CardSide.FRONT);
+
+ final DocumentSubmitRequest.DocumentMetadata page2 = new DocumentSubmitRequest.DocumentMetadata();
+ page2.setFilename("id_card_back.png");
+ page2.setType(DocumentType.ID_CARD);
+ page2.setSide(CardSide.BACK);
+
+ return List.of(page1, page2);
+ }
+
+ private List createIdCardData() {
+ final Document documentPage1 = new Document();
+ documentPage1.setData("img1".getBytes());
+ documentPage1.setFilename("id_card_front.png");
+
+ final Document documentPage2 = new Document();
+ documentPage2.setData("img2".getBytes());
+ documentPage2.setFilename("id_card_back.png");
+
+ return List.of(documentPage1, documentPage2);
+ }
+
+ private OwnerId createOwnerId() {
+ final OwnerId ownerId = new OwnerId();
+ ownerId.setActivationId("a1");
+ ownerId.setUserId("u1");
+ return ownerId;
}
}
diff --git a/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.sql b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.sql
new file mode 100644
index 000000000..a8d5f54f3
--- /dev/null
+++ b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.sql
@@ -0,0 +1,2 @@
+INSERT INTO es_identity_verification(id, activation_id, user_id, process_id, status, phase, timestamp_created) VALUES
+ ('v1', 'a1', 'u1', 'p1', 'IN_PROGRESS', 'DOCUMENT_UPLOAD', now());
diff --git a/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testPairTwoSidedDocuments.sql b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testPairTwoSidedDocuments.sql
new file mode 100644
index 000000000..7f5ef45c6
--- /dev/null
+++ b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testPairTwoSidedDocuments.sql
@@ -0,0 +1,6 @@
+INSERT INTO es_identity_verification(id, activation_id, user_id, process_id, status, phase, timestamp_created) VALUES
+ ('v1', 'a1', 'u1', 'p1', 'VERIFICATION_PENDING', 'DOCUMENT_VERIFICATION', now());
+
+INSERT INTO es_document_verification(id, activation_id, identity_verification_id, type, side, status, used_for_verification, filename, timestamp_created) VALUES
+ ('1', 'a1', 'v1', 'ID_CARD', 'FRONT', 'VERIFICATION_PENDING', true, 'id_front.png', now()),
+ ('2', 'a1', 'v1', 'ID_CARD', 'BACK', 'VERIFICATION_PENDING', true, 'id_back.png', now());
diff --git a/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testResubmitDocuments.sql b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testResubmitDocuments.sql
new file mode 100644
index 000000000..ecaf09aa7
--- /dev/null
+++ b/enrollment-server-onboarding/src/test/resources/com/wultra/app/onboardingserver/impl/service/document/DocumentProcessingServiceTest.testResubmitDocuments.sql
@@ -0,0 +1,6 @@
+INSERT INTO es_identity_verification(id, activation_id, user_id, process_id, status, phase, timestamp_created) VALUES
+ ('v1', 'a1', 'u1', 'p1', 'VERIFICATION_PENDING', 'DOCUMENT_VERIFICATION', now());
+
+INSERT INTO es_document_verification(id, activation_id, identity_verification_id, type, side, status, used_for_verification, filename, timestamp_created) VALUES
+ ('original1', 'a1', 'v1', 'ID_CARD', 'FRONT', 'VERIFICATION_PENDING', true, 'original_id_front.png', now()),
+ ('original2', 'a1', 'v1', 'ID_CARD', 'BACK', 'VERIFICATION_PENDING', true, 'original_id_back.png', now());
\ No newline at end of file