From 97d40f26d01930a2b6c31ec79a06b8c81370ac47 Mon Sep 17 00:00:00 2001
From: Matthias Urhahn <matthias.urhahn@sap.com>
Date: Thu, 10 Jun 2021 20:03:13 +0200
Subject: [PATCH] Handle semi-invalid vaccination certificate QR-codes
 (EXPOSUREAPP-7755) (#3414)

* Introduce qrcode data parsing modes.
UI based parsing will be STRICT and show error codes.
Storage based parsing will be LENIENT.
If the user managed to scan something invalid, then we can neither just delete it, nor just crash the app.

* LINTs

* Fix plural typo.

Co-authored-by: Mohamed Metwalli <mohamed.metwalli@sap.com>
---
 .../qrcode/CoronaTestQRCodeValidator.kt       | 10 +++-
 .../coronatest/qrcode/PcrQrCodeExtractor.kt   |  2 +-
 .../qrcode/RapidAntigenQrCodeExtractor.kt     |  2 +-
 .../InvalidHealthCertificateException.kt      |  5 ++
 .../certificate/VaccinationDGCV1Parser.kt     | 37 ++++++++-----
 .../core/qrcode/VaccinationQRCodeExtractor.kt |  8 +--
 .../core/qrcode/VaccinationQRCodeValidator.kt |  2 +-
 .../storage/VaccinationContainer.kt           |  3 +-
 .../qrcode/CoronaTestQrCodeValidatorTest.kt   | 20 +++++--
 .../qrcode/PcrQrCodeExtractorTest.kt          | 48 +++++++++++++----
 .../qrcode/RapidAntigenQrCodeExtractorTest.kt | 11 ++--
 .../core/VaccinationTestComponent.kt          |  2 +
 .../vaccination/core/VaccinationTestData.kt   | 10 ++++
 .../qrcode/VaccinationQRCodeExtractorTest.kt  | 52 ++++++++++++++-----
 .../qrcode/VaccinationQrCodeValidatorTest.kt  | 34 ++++++++++++
 .../storage/VaccinationContainerTest.kt       | 28 ++++++++++
 16 files changed, 221 insertions(+), 53 deletions(-)
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQrCodeValidatorTest.kt

diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidator.kt
index 7b31a9773..4f12913e4 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidator.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidator.kt
@@ -13,7 +13,7 @@ class CoronaTestQrCodeValidator @Inject constructor(
 
     fun validate(rawString: String): CoronaTestQRCode {
         return findExtractor(rawString)
-            ?.extract(rawString)
+            ?.extract(rawString, mode = QrCodeExtractor.Mode.TEST_STRICT)
             ?.also { Timber.i("Extracted data from QR code is %s", it) }
             ?: throw InvalidQRCodeException()
     }
@@ -25,5 +25,11 @@ class CoronaTestQrCodeValidator @Inject constructor(
 
 interface QrCodeExtractor<T> {
     fun canHandle(rawString: String): Boolean
-    fun extract(rawString: String): T
+    fun extract(rawString: String, mode: Mode): T
+
+    enum class Mode {
+        TEST_STRICT,
+        CERT_VAC_STRICT,
+        CERT_VAC_LENIENT
+    }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractor.kt
index 49633e3de..c94eb873f 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractor.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractor.kt
@@ -8,7 +8,7 @@ class PcrQrCodeExtractor @Inject constructor() : QrCodeExtractor<CoronaTestQRCod
 
     override fun canHandle(rawString: String): Boolean = rawString.startsWith(prefix, ignoreCase = true)
 
-    override fun extract(rawString: String): CoronaTestQRCode.PCR {
+    override fun extract(rawString: String, mode: QrCodeExtractor.Mode): CoronaTestQRCode.PCR {
         val guid = extractGUID(rawString)
         PcrQrCodeCensor.lastGUID = guid
         return CoronaTestQRCode.PCR(guid)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractor.kt
index c5881f973..96dd01a71 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractor.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractor.kt
@@ -19,7 +19,7 @@ class RapidAntigenQrCodeExtractor @Inject constructor() : QrCodeExtractor<Corona
         return rawString.startsWith(PREFIX1, ignoreCase = true) || rawString.startsWith(PREFIX2, ignoreCase = true)
     }
 
-    override fun extract(rawString: String): CoronaTestQRCode.RapidAntigen {
+    override fun extract(rawString: String, mode: QrCodeExtractor.Mode): CoronaTestQRCode.RapidAntigen {
         Timber.v("extract(rawString=%s)", rawString)
         val payload = CleanPayload(extractData(rawString))
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/certificate/InvalidHealthCertificateException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/certificate/InvalidHealthCertificateException.kt
index 39061747c..14110a6f2 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/certificate/InvalidHealthCertificateException.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/certificate/InvalidHealthCertificateException.kt
@@ -19,6 +19,7 @@ import de.rki.coronawarnapp.vaccination.core.certificate.InvalidHealthCertificat
 import de.rki.coronawarnapp.vaccination.core.certificate.InvalidHealthCertificateException.ErrorCode.VC_HC_CWT_NO_HCERT
 import de.rki.coronawarnapp.vaccination.core.certificate.InvalidHealthCertificateException.ErrorCode.VC_HC_CWT_NO_ISS
 import de.rki.coronawarnapp.vaccination.core.certificate.InvalidHealthCertificateException.ErrorCode.VC_JSON_SCHEMA_INVALID
+import de.rki.coronawarnapp.vaccination.core.certificate.InvalidHealthCertificateException.ErrorCode.VC_MULTIPLE_VACCINATION_ENTRIES
 import de.rki.coronawarnapp.vaccination.core.certificate.InvalidHealthCertificateException.ErrorCode.VC_NAME_MISMATCH
 import de.rki.coronawarnapp.vaccination.core.certificate.InvalidHealthCertificateException.ErrorCode.VC_NO_VACCINATION_ENTRY
 import de.rki.coronawarnapp.vaccination.core.certificate.InvalidHealthCertificateException.ErrorCode.VC_PREFIX_INVALID
@@ -36,6 +37,7 @@ class InvalidHealthCertificateException(
         HC_COSE_MESSAGE_INVALID("COSE message invalid."),
         HC_CBOR_DECODING_FAILED("CBOR decoding failed."),
         VC_NO_VACCINATION_ENTRY("Vaccination certificate missing."),
+        VC_MULTIPLE_VACCINATION_ENTRIES("Multiple vaccination certificates."),
         VC_PREFIX_INVALID("Prefix invalid."),
         VC_STORING_FAILED("Storing failed."),
         VC_JSON_SCHEMA_INVALID("Json schema invalid."),
@@ -73,6 +75,9 @@ class InvalidHealthCertificateException(
             VC_NO_VACCINATION_ENTRY -> CachedString { context ->
                 context.getString(ERROR_MESSAGE_VC_NOT_YET_SUPPORTED)
             }
+            VC_MULTIPLE_VACCINATION_ENTRIES -> CachedString { context ->
+                context.getString(ERROR_MESSAGE_VC_NOT_YET_SUPPORTED)
+            }
             VC_STORING_FAILED -> CachedString { context ->
                 context.getString(ERROR_MESSAGE_VC_SCAN_AGAIN)
             }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/certificate/VaccinationDGCV1Parser.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/certificate/VaccinationDGCV1Parser.kt
index e25defbc9..be296d648 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/certificate/VaccinationDGCV1Parser.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/certificate/VaccinationDGCV1Parser.kt
@@ -9,7 +9,9 @@ import de.rki.coronawarnapp.vaccination.core.certificate.InvalidHealthCertificat
 import de.rki.coronawarnapp.vaccination.core.certificate.InvalidHealthCertificateException.ErrorCode.VC_HC_CWT_NO_DGC
 import de.rki.coronawarnapp.vaccination.core.certificate.InvalidHealthCertificateException.ErrorCode.VC_HC_CWT_NO_HCERT
 import de.rki.coronawarnapp.vaccination.core.certificate.InvalidHealthCertificateException.ErrorCode.VC_JSON_SCHEMA_INVALID
+import de.rki.coronawarnapp.vaccination.core.certificate.InvalidHealthCertificateException.ErrorCode.VC_MULTIPLE_VACCINATION_ENTRIES
 import de.rki.coronawarnapp.vaccination.core.certificate.InvalidHealthCertificateException.ErrorCode.VC_NO_VACCINATION_ENTRY
+import timber.log.Timber
 import javax.inject.Inject
 
 @Reusable
@@ -17,35 +19,44 @@ class VaccinationDGCV1Parser @Inject constructor(
     @BaseGson private val gson: Gson
 ) {
 
-    fun parse(map: CBORObject): VaccinationDGCV1 = try {
+    fun parse(map: CBORObject, lenient: Boolean): VaccinationDGCV1 = try {
         val certificate: VaccinationDGCV1 = map[keyHCert]?.run {
             this[keyEuDgcV1]?.run {
                 toCertificate()
             } ?: throw InvalidHealthCertificateException(VC_HC_CWT_NO_DGC)
         } ?: throw InvalidHealthCertificateException(VC_HC_CWT_NO_HCERT)
 
-        certificate.validate()
+        certificate.toValidated(lenient)
     } catch (e: InvalidHealthCertificateException) {
         throw e
     } catch (e: Throwable) {
         throw InvalidHealthCertificateException(HC_CBOR_DECODING_FAILED)
     }
 
-    private fun VaccinationDGCV1.validate(): VaccinationDGCV1 {
-        if (vaccinationDatas.isEmpty()) {
-            throw InvalidHealthCertificateException(VC_NO_VACCINATION_ENTRY)
+    private fun VaccinationDGCV1.toValidated(lenient: Boolean): VaccinationDGCV1 = this
+        .run {
+            if (vaccinationDatas.isEmpty()) throw InvalidHealthCertificateException(VC_NO_VACCINATION_ENTRY)
+
+            if (vaccinationDatas.size == 1) return@run this
+
+            if (lenient) {
+                Timber.w("Lenient: Vaccination data contained multiple entries.")
+                copy(vaccinationDatas = listOf(vaccinationDatas.maxByOrNull { it.vaccinatedAt }!!))
+            } else {
+                throw InvalidHealthCertificateException(VC_MULTIPLE_VACCINATION_ENTRIES)
+            }
         }
-        // Force date parsing
-        dateOfBirth
-        vaccinationDatas.forEach {
-            it.vaccinatedAt
+        .also {
+            // Force date parsing
+            dateOfBirth
+            vaccinationDatas.forEach {
+                it.vaccinatedAt
+            }
         }
-        return this
-    }
 
-    private fun CBORObject.toCertificate() = try {
+    private fun CBORObject.toCertificate(): VaccinationDGCV1 = try {
         val json = ToJSONString()
-        gson.fromJson<VaccinationDGCV1>(json)
+        gson.fromJson(json)
     } catch (e: Throwable) {
         throw InvalidHealthCertificateException(VC_JSON_SCHEMA_INVALID)
     }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractor.kt
index ae3065df3..d1d9496bf 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractor.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractor.kt
@@ -22,14 +22,14 @@ class VaccinationQRCodeExtractor @Inject constructor(
 
     override fun canHandle(rawString: String): Boolean = rawString.startsWith(PREFIX)
 
-    override fun extract(rawString: String): VaccinationCertificateQRCode {
+    override fun extract(rawString: String, mode: QrCodeExtractor.Mode): VaccinationCertificateQRCode {
         CertificateQrCodeCensor.addQRCodeStringToCensor(rawString)
 
         val parsedData = rawString
             .removePrefix(PREFIX)
             .decodeBase45()
             .decompress()
-            .parse()
+            .parse(lenient = mode == QrCodeExtractor.Mode.CERT_VAC_LENIENT)
 
         return VaccinationCertificateQRCode(
             parsedData = parsedData,
@@ -51,13 +51,13 @@ class VaccinationQRCodeExtractor @Inject constructor(
         throw InvalidHealthCertificateException(HC_ZLIB_DECOMPRESSION_FAILED)
     }
 
-    fun RawCOSEObject.parse(): VaccinationCertificateData {
+    fun RawCOSEObject.parse(lenient: Boolean): VaccinationCertificateData {
         Timber.v("Parsing COSE for vaccination certificate.")
         val cbor = coseDecoder.decode(this)
 
         return VaccinationCertificateData(
             header = headerParser.parse(cbor),
-            certificate = bodyParser.parse(cbor)
+            certificate = bodyParser.parse(cbor, lenient = lenient)
         ).also {
             CertificateQrCodeCensor.addCertificateToCensor(it)
         }.also {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeValidator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeValidator.kt
index 3e7f60396..aefdff6e5 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeValidator.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeValidator.kt
@@ -17,7 +17,7 @@ class VaccinationQRCodeValidator @Inject constructor(
         // If there is more than one "extractor" in the future, check censoring again.
         // CertificateQrCodeCensor.addQRCodeStringToCensor(rawString)
         return findExtractor(rawString)
-            ?.extract(rawString)
+            ?.extract(rawString, mode = QrCodeExtractor.Mode.CERT_VAC_STRICT)
             ?.also { Timber.i("Extracted data from QR code is %s", it) }
             ?: throw InvalidHealthCertificateException(VC_PREFIX_INVALID)
     }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainer.kt
index 9694f5f9d..7914dd278 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainer.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainer.kt
@@ -2,6 +2,7 @@ package de.rki.coronawarnapp.vaccination.core.repository.storage
 
 import androidx.annotation.Keep
 import com.google.gson.annotations.SerializedName
+import de.rki.coronawarnapp.coronatest.qrcode.QrCodeExtractor.Mode
 import de.rki.coronawarnapp.vaccination.core.VaccinatedPersonIdentifier
 import de.rki.coronawarnapp.vaccination.core.VaccinationCertificate
 import de.rki.coronawarnapp.vaccination.core.certificate.CoseCertificateHeader
@@ -33,7 +34,7 @@ data class VaccinationContainer internal constructor(
 
     @delegate:Transient
     internal val certificateData: VaccinationCertificateData by lazy {
-        preParsedData ?: qrCodeExtractor.extract(vaccinationQrCode).parsedData
+        preParsedData ?: qrCodeExtractor.extract(vaccinationQrCode, mode = Mode.CERT_VAC_LENIENT).parsedData
     }
 
     val header: CoseCertificateHeader
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQrCodeValidatorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQrCodeValidatorTest.kt
index f9665d0af..e5cdb3f4c 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQrCodeValidatorTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQrCodeValidatorTest.kt
@@ -1,16 +1,21 @@
 package de.rki.coronawarnapp.coronatest.qrcode
 
+import de.rki.coronawarnapp.coronatest.qrcode.QrCodeExtractor.Mode
 import de.rki.coronawarnapp.coronatest.type.CoronaTest
 import io.kotest.assertions.throwables.shouldThrow
 import io.kotest.matchers.shouldBe
+import io.mockk.spyk
+import io.mockk.verify
 import org.junit.jupiter.api.Test
 import testhelpers.BaseTest
 
 class CoronaTestQrCodeValidatorTest : BaseTest() {
+    private val raExtractor = spyk(RapidAntigenQrCodeExtractor())
+    private val pcrExtractor = spyk(PcrQrCodeExtractor())
 
     @Test
     fun `valid codes are extracted by corresponding extractor`() {
-        val instance = CoronaTestQrCodeValidator(RapidAntigenQrCodeExtractor(), PcrQrCodeExtractor())
+        val instance = CoronaTestQrCodeValidator(raExtractor, pcrExtractor)
         instance.validate(pcrQrCode1).type shouldBe CoronaTest.Type.PCR
         instance.validate(pcrQrCode2).type shouldBe CoronaTest.Type.PCR
         instance.validate(pcrQrCode3).type shouldBe CoronaTest.Type.PCR
@@ -22,7 +27,7 @@ class CoronaTestQrCodeValidatorTest : BaseTest() {
     @Test
     fun `invalid prefix throws exception`() {
         val invalidCode = "HTTPS://somethingelse/?123456-12345678-1234-4DA7-B166-B86D85475064"
-        val instance = CoronaTestQrCodeValidator(RapidAntigenQrCodeExtractor(), PcrQrCodeExtractor())
+        val instance = CoronaTestQrCodeValidator(raExtractor, pcrExtractor)
         shouldThrow<InvalidQRCodeException> {
             instance.validate(invalidCode)
         }
@@ -31,9 +36,18 @@ class CoronaTestQrCodeValidatorTest : BaseTest() {
     @Test
     fun `invalid json throws exception`() {
         val invalidCode = "https://s.coronawarn.app/?v=1#eyJ0aW1lc3RhbXAiOjE2"
-        val instance = CoronaTestQrCodeValidator(RapidAntigenQrCodeExtractor(), PcrQrCodeExtractor())
+        val instance = CoronaTestQrCodeValidator(raExtractor, pcrExtractor)
         shouldThrow<InvalidQRCodeException> {
             instance.validate(invalidCode)
         }
     }
+
+    @Test
+    fun `validator uses strict extraction mode`() {
+        val instance = CoronaTestQrCodeValidator(raExtractor, pcrExtractor)
+        instance.validate(pcrQrCode1).type shouldBe CoronaTest.Type.PCR
+        verify { pcrExtractor.extract(pcrQrCode1, Mode.TEST_STRICT) }
+        instance.validate(raQrCode1).type shouldBe CoronaTest.Type.RAPID_ANTIGEN
+        verify { raExtractor.extract(raQrCode1, Mode.TEST_STRICT) }
+    }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractorTest.kt
index e4537ff5a..86e181271 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractorTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractorTest.kt
@@ -1,5 +1,6 @@
 package de.rki.coronawarnapp.coronatest.qrcode
 
+import de.rki.coronawarnapp.coronatest.qrcode.QrCodeExtractor.Mode.TEST_STRICT
 import io.kotest.matchers.shouldBe
 import org.junit.Test
 import testhelpers.BaseTest
@@ -16,7 +17,7 @@ class PcrQrCodeExtractorTest : BaseTest() {
         val extractor = PcrQrCodeExtractor()
         try {
             if (extractor.canHandle("$prefixString$guid")) {
-                extractor.extract("$prefixString$guid")
+                extractor.extract("$prefixString$guid", mode = TEST_STRICT)
                 conditionToMatch shouldBe true
             } else {
                 conditionToMatch shouldBe false
@@ -77,16 +78,43 @@ class PcrQrCodeExtractorTest : BaseTest() {
 
     @Test
     fun extractGUID() {
-        PcrQrCodeExtractor().extract("$localhostUpperCase$guidUpperCase").qrCodeGUID shouldBe guidUpperCase
-        PcrQrCodeExtractor().extract("$localhostUpperCase$guidLowerCase").qrCodeGUID shouldBe guidLowerCase
-        PcrQrCodeExtractor().extract("$localhostUpperCase$guidMixedCase").qrCodeGUID shouldBe guidMixedCase
+        PcrQrCodeExtractor().extract(
+            "$localhostUpperCase$guidUpperCase",
+            mode = TEST_STRICT
+        ).qrCodeGUID shouldBe guidUpperCase
+        PcrQrCodeExtractor().extract(
+            "$localhostUpperCase$guidLowerCase",
+            mode = TEST_STRICT
+        ).qrCodeGUID shouldBe guidLowerCase
+        PcrQrCodeExtractor().extract(
+            "$localhostUpperCase$guidMixedCase",
+            mode = TEST_STRICT
+        ).qrCodeGUID shouldBe guidMixedCase
 
-        PcrQrCodeExtractor().extract("$localhostLowerCase$guidUpperCase").qrCodeGUID shouldBe guidUpperCase
-        PcrQrCodeExtractor().extract("$localhostLowerCase$guidLowerCase").qrCodeGUID shouldBe guidLowerCase
-        PcrQrCodeExtractor().extract("$localhostLowerCase$guidMixedCase").qrCodeGUID shouldBe guidMixedCase
+        PcrQrCodeExtractor().extract(
+            "$localhostLowerCase$guidUpperCase",
+            mode = TEST_STRICT
+        ).qrCodeGUID shouldBe guidUpperCase
+        PcrQrCodeExtractor().extract(
+            "$localhostLowerCase$guidLowerCase",
+            mode = TEST_STRICT
+        ).qrCodeGUID shouldBe guidLowerCase
+        PcrQrCodeExtractor().extract(
+            "$localhostLowerCase$guidMixedCase",
+            mode = TEST_STRICT
+        ).qrCodeGUID shouldBe guidMixedCase
 
-        PcrQrCodeExtractor().extract("$localhostMixedCase$guidUpperCase").qrCodeGUID shouldBe guidUpperCase
-        PcrQrCodeExtractor().extract("$localhostMixedCase$guidLowerCase").qrCodeGUID shouldBe guidLowerCase
-        PcrQrCodeExtractor().extract("$localhostMixedCase$guidMixedCase").qrCodeGUID shouldBe guidMixedCase
+        PcrQrCodeExtractor().extract(
+            "$localhostMixedCase$guidUpperCase",
+            mode = TEST_STRICT
+        ).qrCodeGUID shouldBe guidUpperCase
+        PcrQrCodeExtractor().extract(
+            "$localhostMixedCase$guidLowerCase",
+            mode = TEST_STRICT
+        ).qrCodeGUID shouldBe guidLowerCase
+        PcrQrCodeExtractor().extract(
+            "$localhostMixedCase$guidMixedCase",
+            mode = TEST_STRICT
+        ).qrCodeGUID shouldBe guidMixedCase
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractorTest.kt
index e78658b69..4a746b6ed 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractorTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractorTest.kt
@@ -1,5 +1,6 @@
 package de.rki.coronawarnapp.coronatest.qrcode
 
+import de.rki.coronawarnapp.coronatest.qrcode.QrCodeExtractor.Mode.TEST_STRICT
 import de.rki.coronawarnapp.coronatest.type.CoronaTest
 import io.kotest.assertions.throwables.shouldThrow
 import io.kotest.matchers.shouldBe
@@ -29,13 +30,13 @@ class RapidAntigenQrCodeExtractorTest : BaseTest() {
     @Test
     fun `extracting valid codes does not throw exception`() {
         listOf(raQrCode1, raQrCode2, raQrCode3, raQrCode4, raQrCode5, raQrCode6, raQrCode7, raQrCode8).forEach {
-            instance.extract(it)
+            instance.extract(it, mode = TEST_STRICT)
         }
     }
 
     @Test
     fun `personal data is extracted`() {
-        val data = instance.extract(raQrCode3)
+        val data = instance.extract(raQrCode3, mode = TEST_STRICT)
         data.type shouldBe CoronaTest.Type.RAPID_ANTIGEN
         data.hash shouldBe "7dce08db0d4abd5ac1d2498b571afb221ca947c75c847d05466b4cfe9d95dc66"
         data.createdAt shouldBe Instant.ofEpochMilli(1619618352000)
@@ -46,7 +47,7 @@ class RapidAntigenQrCodeExtractorTest : BaseTest() {
 
     @Test
     fun `empty strings are treated as null or notset`() {
-        val data = instance.extract(raQrCodeEmptyStrings)
+        val data = instance.extract(raQrCodeEmptyStrings, mode = TEST_STRICT)
         data.type shouldBe CoronaTest.Type.RAPID_ANTIGEN
         data.hash shouldBe "d6e4d0181d8109bf05b346a0d2e0ef0cc472eed70d9df8c4b9ae5c7a009f3e34"
         data.createdAt shouldBe Instant.ofEpochMilli(1619012952000)
@@ -57,14 +58,14 @@ class RapidAntigenQrCodeExtractorTest : BaseTest() {
 
     @Test
     fun `personal data is only valid if complete or completely missing`() {
-        shouldThrow<InvalidQRCodeException> { instance.extract(raQrIncompletePersonalData) }
+        shouldThrow<InvalidQRCodeException> { instance.extract(raQrIncompletePersonalData, mode = TEST_STRICT) }
     }
 
     @Test
     fun `invalid json throws exception`() {
         val invalidCode = "https://s.coronawarn.app/?v=1#eyJ0aW1lc3RhbXAiOjE2"
         shouldThrow<InvalidQRCodeException> {
-            RapidAntigenQrCodeExtractor().extract(invalidCode)
+            RapidAntigenQrCodeExtractor().extract(invalidCode, mode = TEST_STRICT)
         }
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestComponent.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestComponent.kt
index 181970bd6..7f80b1fbb 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestComponent.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestComponent.kt
@@ -4,6 +4,7 @@ import dagger.Component
 import dagger.Module
 import de.rki.coronawarnapp.util.serialization.SerializationModule
 import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationQRCodeExtractorTest
+import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationQrCodeValidatorTest
 import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepositoryTest
 import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinationContainerTest
 import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinationStorageTest
@@ -23,6 +24,7 @@ interface VaccinationTestComponent {
     fun inject(testClass: VaccinationQRCodeExtractorTest)
     fun inject(testClass: VaccinatedPersonTest)
     fun inject(testClass: VaccinationRepositoryTest)
+    fun inject(testClass: VaccinationQrCodeValidatorTest)
 
     @Component.Factory
     interface Factory {
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestData.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestData.kt
index 0c5117d63..9ff6c95e5 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestData.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestData.kt
@@ -184,4 +184,14 @@ class VaccinationTestData @Inject constructor(
     ).apply {
         qrCodeExtractor = this@VaccinationTestData.qrCodeExtractor
     }
+
+    val personYVacTwoEntriesQrCode =
+        "HC1:6BFOXN%TSMAHN-HVN8J7UQMJ4/36 L-AHQ+R1WG%MP8*ICG5QKM0658WAULO8NASA3/-2E%5G%5TW5A 6YO6XL6Q3QR\$P*NI92KV6TKOJ06JYZJV1JJ7UGOJUTIJ7J:ZJ83BL8TFVTV9T.ZJC0J*PIZ.TJ STPT*IJ5OI9YI:8DJ:D%PDDIKIWCHAB.YMAHLW 70SO:GOLIROGO3T59YLY1S7HOPC5NDOEC5/64ND7BT5PE4D/5:/6N9R%EPXCROGO+GOVIR-PQ395R4IUHLW\$G-B5ET42HPPEPHCR6W97DON95N14Q6SP+PJD1W9L \$N3-Q.VBAO8MN9*QHAO96Y2/*13A5-8E6V59I9BZK6:IZW4I:A6J3ARN QT1BGL4OMJKR.K\$A1EB14UVC2O+5T3.CE1M33KS2JKA8Y*99CCLLOR/CH0GRP8 GLY 1LA7551DC2U.NVOTJOII:8DKEK%N92T9YQ$0MK%P6\$G9K7QQUY9KI.EK*8XRS-DPA5W64SMVR1NF6D0 2S0.7R:ASENTI094PIDS:T32DRE8N"
+
+    val personYVacTwoEntriesContainer = VaccinationContainer(
+        scannedAt = Instant.ofEpochMilli(1620062834471),
+        vaccinationQrCode = personYVacTwoEntriesQrCode,
+    ).apply {
+        qrCodeExtractor = this@VaccinationTestData.qrCodeExtractor
+    }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt
index 1b41ddfa0..0c7670d9a 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt
@@ -1,5 +1,6 @@
 package de.rki.coronawarnapp.vaccination.core.qrcode
 
+import de.rki.coronawarnapp.coronatest.qrcode.QrCodeExtractor.Mode
 import de.rki.coronawarnapp.vaccination.core.DaggerVaccinationTestComponent
 import de.rki.coronawarnapp.vaccination.core.VaccinationQrCodeTestData
 import de.rki.coronawarnapp.vaccination.core.VaccinationTestData
@@ -29,17 +30,17 @@ class VaccinationQRCodeExtractorTest : BaseTest() {
 
     @Test
     fun `happy path extraction`() {
-        extractor.extract(VaccinationQrCodeTestData.validVaccinationQrCode)
+        extractor.extract(VaccinationQrCodeTestData.validVaccinationQrCode, mode = Mode.CERT_VAC_STRICT)
     }
 
     @Test
     fun `happy path extraction 2`() {
-        extractor.extract(VaccinationQrCodeTestData.validVaccinationQrCode2)
+        extractor.extract(VaccinationQrCodeTestData.validVaccinationQrCode2, mode = Mode.CERT_VAC_STRICT)
     }
 
     @Test
     fun `happy path extraction with data`() {
-        val qrCode = extractor.extract(VaccinationQrCodeTestData.validVaccinationQrCode3)
+        val qrCode = extractor.extract(VaccinationQrCodeTestData.validVaccinationQrCode3, mode = Mode.CERT_VAC_STRICT)
 
         with(qrCode.parsedData.header) {
             issuer shouldBe "AT"
@@ -76,52 +77,76 @@ class VaccinationQRCodeExtractorTest : BaseTest() {
 
     @Test
     fun `happy path extraction 4`() {
-        extractor.extract(VaccinationQrCodeTestData.validVaccinationQrCode4)
+        extractor.extract(
+            VaccinationQrCodeTestData.validVaccinationQrCode4,
+            mode = Mode.CERT_VAC_STRICT
+        )
     }
 
     @Test
     fun `valid encoding but not a health certificate fails with VC_HC_CWT_NO_ISS`() {
         shouldThrow<InvalidHealthCertificateException> {
-            extractor.extract(VaccinationQrCodeTestData.validEncoded)
+            extractor.extract(
+                VaccinationQrCodeTestData.validEncoded,
+                mode = Mode.CERT_VAC_STRICT
+            )
         }.errorCode shouldBe VC_HC_CWT_NO_ISS
     }
 
     @Test
     fun `random string fails with HC_BASE45_DECODING_FAILED`() {
         shouldThrow<InvalidHealthCertificateException> {
-            extractor.extract("nothing here to see")
+            extractor.extract(
+                "nothing here to see",
+                mode = Mode.CERT_VAC_STRICT
+            )
         }.errorCode shouldBe HC_BASE45_DECODING_FAILED
     }
 
     @Test
     fun `uncompressed base45 string fails with HC_ZLIB_DECOMPRESSION_FAILED`() {
         shouldThrow<InvalidHealthCertificateException> {
-            extractor.extract("6BFOABCDEFGHIJKLMNOPQRSTUVWXYZ %*+-./:")
+            extractor.extract(
+                "6BFOABCDEFGHIJKLMNOPQRSTUVWXYZ %*+-./:",
+                mode = Mode.CERT_VAC_STRICT
+            )
         }.errorCode shouldBe HC_ZLIB_DECOMPRESSION_FAILED
     }
 
     @Test
     fun `vaccination certificate missing fails with VC_NO_VACCINATION_ENTRY`() {
         shouldThrow<InvalidHealthCertificateException> {
-            extractor.extract(VaccinationQrCodeTestData.certificateMissing)
+            extractor.extract(
+                VaccinationQrCodeTestData.certificateMissing,
+                mode = Mode.CERT_VAC_STRICT
+            )
         }.errorCode shouldBe VC_NO_VACCINATION_ENTRY
     }
 
     @Test
     fun `test data person A check`() {
-        val extracted = extractor.extract(vaccinationTestData.personAVac1QRCodeString)
+        val extracted = extractor.extract(
+            vaccinationTestData.personAVac1QRCodeString,
+            mode = Mode.CERT_VAC_STRICT
+        )
         extracted shouldBe vaccinationTestData.personAVac1QRCode
     }
 
     @Test
     fun `test data person B check`() {
-        val extracted = extractor.extract(vaccinationTestData.personBVac1QRCodeString)
+        val extracted = extractor.extract(
+            vaccinationTestData.personBVac1QRCodeString,
+            mode = Mode.CERT_VAC_STRICT
+        )
         extracted shouldBe vaccinationTestData.personBVac1QRCode
     }
 
     @Test
     fun `Bulgarian qr code passes`() {
-        val qrCode = extractor.extract(VaccinationQrCodeTestData.qrCodeBulgaria)
+        val qrCode = extractor.extract(
+            VaccinationQrCodeTestData.qrCodeBulgaria,
+            mode = Mode.CERT_VAC_STRICT
+        )
         with(qrCode.parsedData.header) {
             issuer shouldBe "BG"
             issuedAt shouldBe Instant.parse("2021-06-02T14:07:56.000Z")
@@ -157,6 +182,9 @@ class VaccinationQRCodeExtractorTest : BaseTest() {
 
     @Test
     fun `Swedish qr code passes`() {
-        extractor.extract(VaccinationQrCodeTestData.qrCodeSweden)
+        extractor.extract(
+            VaccinationQrCodeTestData.qrCodeSweden,
+            mode = Mode.CERT_VAC_STRICT
+        )
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQrCodeValidatorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQrCodeValidatorTest.kt
new file mode 100644
index 000000000..9c08a3e0e
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQrCodeValidatorTest.kt
@@ -0,0 +1,34 @@
+package de.rki.coronawarnapp.vaccination.core.qrcode
+
+import de.rki.coronawarnapp.coronatest.qrcode.QrCodeExtractor.Mode
+import de.rki.coronawarnapp.vaccination.core.DaggerVaccinationTestComponent
+import de.rki.coronawarnapp.vaccination.core.VaccinationTestData
+import io.kotest.matchers.shouldBe
+import io.mockk.spyk
+import io.mockk.verify
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import javax.inject.Inject
+
+class VaccinationQrCodeValidatorTest : BaseTest() {
+    @Inject lateinit var testData: VaccinationTestData
+    @Inject lateinit var vacExtractor: VaccinationQRCodeExtractor
+    private lateinit var vacExtractorSpy: VaccinationQRCodeExtractor
+
+    @BeforeEach
+    fun setup() {
+        DaggerVaccinationTestComponent.factory().create().inject(this)
+
+        vacExtractorSpy = spyk(vacExtractor)
+    }
+
+    @Test
+    fun `validator uses strict extraction mode`() {
+        val instance = VaccinationQRCodeValidator(vacExtractorSpy)
+        instance.validate(testData.personAVac1QRCodeString).apply {
+            uniqueCertificateIdentifier shouldBe testData.personAVac1Container.certificateId
+        }
+        verify { vacExtractorSpy.extract(testData.personAVac1QRCodeString, Mode.CERT_VAC_STRICT) }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainerTest.kt
index fad39f683..a2817d544 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainerTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainerTest.kt
@@ -1,12 +1,17 @@
 package de.rki.coronawarnapp.vaccination.core.repository.storage
 
+import de.rki.coronawarnapp.coronatest.qrcode.QrCodeExtractor
 import de.rki.coronawarnapp.vaccination.core.DaggerVaccinationTestComponent
 import de.rki.coronawarnapp.vaccination.core.VaccinatedPersonIdentifier
 import de.rki.coronawarnapp.vaccination.core.VaccinationTestData
+import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateQRCode
+import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationQRCodeExtractor
 import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationValueSet
 import io.kotest.matchers.shouldBe
+import io.kotest.matchers.shouldNotBe
 import io.mockk.every
 import io.mockk.mockk
+import io.mockk.verify
 import org.joda.time.Instant
 import org.joda.time.LocalDate
 import org.junit.jupiter.api.BeforeEach
@@ -138,4 +143,27 @@ class VaccinationContainerTest : BaseTest() {
             certificateCountry shouldBe "YY"
         }
     }
+
+    @Test
+    fun `default parsing mode for containers is lenient`() {
+        val container = VaccinationContainer(
+            vaccinationQrCode = testData.personYVacTwoEntriesQrCode,
+            scannedAt = Instant.EPOCH
+        )
+        val extractor = mockk<VaccinationQRCodeExtractor>().apply {
+            every { extract(any(), any()) } returns mockk<VaccinationCertificateQRCode>().apply {
+                every { parsedData } returns mockk()
+            }
+        }
+        container.qrCodeExtractor = extractor
+
+        container.certificateData shouldNotBe null
+
+        verify { extractor.extract(testData.personYVacTwoEntriesQrCode, QrCodeExtractor.Mode.CERT_VAC_LENIENT) }
+    }
+
+    @Test
+    fun `gracefully handle semi invalid data - multiple entries`() {
+        testData.personYVacTwoEntriesContainer.certificate.vaccinationDatas.size shouldBe 1
+    }
 }
-- 
GitLab