From 8e85ea3f6f4a697007d641be034abd863c7fe30f Mon Sep 17 00:00:00 2001
From: Matthias Urhahn <matthias.urhahn@sap.com>
Date: Mon, 14 Jun 2021 11:18:08 +0200
Subject: [PATCH] Fix certificate LocalDate parsing being too lenient (DEV)
 (#3426)

Missing month or day elements were assumed to be "start" (e.g. 2021 -> 2021-01-01).

Co-authored-by: harambasicluka <64483219+harambasicluka@users.noreply.github.com>
---
 .../common/certificate/Dcc.kt                 | 37 ++++++++++---
 .../test/core/certificate/TestDccV1.kt        |  6 +--
 .../core/certificate/VaccinationDccV1.kt      | 29 ++--------
 .../core/VaccinationQrCodeTestData.java       | 12 +++++
 .../qrcode/VaccinationQRCodeExtractorTest.kt  | 53 +++++++++++++++++++
 5 files changed, 103 insertions(+), 34 deletions(-)

diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/covidcertificate/common/certificate/Dcc.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/covidcertificate/common/certificate/Dcc.kt
index 8d8c4b030..52d13dd08 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/covidcertificate/common/certificate/Dcc.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/covidcertificate/common/certificate/Dcc.kt
@@ -1,9 +1,14 @@
 package de.rki.coronawarnapp.covidcertificate.common.certificate
 
 import com.google.gson.annotations.SerializedName
+import org.joda.time.DateTime
 import org.joda.time.LocalDate
+import org.joda.time.format.DateTimeFormat
+import org.joda.time.format.DateTimeFormatterBuilder
+import org.joda.time.format.ISODateTimeFormat
+import timber.log.Timber
 
-interface Dcc<PayloadType : Dcc.Payload> {
+abstract class Dcc<PayloadType : Dcc.Payload> {
     data class NameData(
         @SerializedName("fn") internal val familyName: String?,
         @SerializedName("fnt") internal val familyNameStandardized: String,
@@ -23,14 +28,16 @@ interface Dcc<PayloadType : Dcc.Payload> {
             }
     }
 
-    val version: String
-    val nameData: NameData
-    val dob: String
+    abstract val version: String
+    abstract val nameData: NameData
+    abstract val dob: String
 
+    // Can't use lazy because GSON will NULL it, as we have no no-args constructor
+    private var dateOfBirthCache: LocalDate? = null
     val dateOfBirth: LocalDate
-        get() = LocalDate.parse(dob)
+        get() = dateOfBirthCache ?: dob.toLocalDateLeniently().also { dateOfBirthCache = it }
 
-    val payloads: List<PayloadType>
+    abstract val payloads: List<PayloadType>
     val payload: PayloadType
         get() = payloads.single()
 
@@ -48,3 +55,21 @@ interface Dcc<PayloadType : Dcc.Payload> {
         val uniqueCertificateIdentifier: String
     }
 }
+
+internal fun String.toLocalDateLeniently(): LocalDate = try {
+    LocalDate.parse(this, DateTimeFormat.forPattern("yyyy-MM-dd"))
+} catch (e: Exception) {
+    Timber.w("Irregular date string: %s", this)
+    try {
+        DateTime.parse(
+            this,
+            DateTimeFormatterBuilder()
+                .append(ISODateTimeFormat.date())
+                .append(ISODateTimeFormat.timeParser().withOffsetParsed())
+                .toFormatter()
+        ).toLocalDate()
+    } catch (giveUp: Exception) {
+        Timber.e("Invalid date string: %s", this)
+        throw giveUp
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/covidcertificate/test/core/certificate/TestDccV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/covidcertificate/test/core/certificate/TestDccV1.kt
index c10516798..3ebb7e072 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/covidcertificate/test/core/certificate/TestDccV1.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/covidcertificate/test/core/certificate/TestDccV1.kt
@@ -6,10 +6,10 @@ import org.joda.time.Instant
 
 data class TestDccV1(
     @SerializedName("ver") override val version: String,
-    @SerializedName("nam") override val nameData: Dcc.NameData,
+    @SerializedName("nam") override val nameData: NameData,
     @SerializedName("dob") override val dob: String,
     @SerializedName("t") override val payloads: List<TestCertificateData>,
-) : Dcc<TestDccV1.TestCertificateData> {
+) : Dcc<TestDccV1.TestCertificateData>() {
 
     data class TestCertificateData(
         // Disease or agent targeted, e.g. "tg": "840539006"
@@ -34,7 +34,7 @@ data class TestDccV1(
         @SerializedName("is") override val certificateIssuer: String,
         // Unique Certificate Identifier, e.g.  "ci": "urn:uvci:01:NL:PlA8UWS60Z4RZXVALl6GAZ"
         @SerializedName("ci") override val uniqueCertificateIdentifier: String
-    ) : Dcc.Payload {
+    ) : Payload {
 
         val testResultAt: Instant?
             get() = dr?.let { Instant.parse(it) }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/covidcertificate/vaccination/core/certificate/VaccinationDccV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/covidcertificate/vaccination/core/certificate/VaccinationDccV1.kt
index e231e3ed6..5a163335c 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/covidcertificate/vaccination/core/certificate/VaccinationDccV1.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/covidcertificate/vaccination/core/certificate/VaccinationDccV1.kt
@@ -2,16 +2,15 @@ package de.rki.coronawarnapp.covidcertificate.vaccination.core.certificate
 
 import com.google.gson.annotations.SerializedName
 import de.rki.coronawarnapp.covidcertificate.common.certificate.Dcc
-import org.joda.time.DateTime
+import de.rki.coronawarnapp.covidcertificate.common.certificate.toLocalDateLeniently
 import org.joda.time.LocalDate
-import timber.log.Timber
 
 data class VaccinationDccV1(
     @SerializedName("ver") override val version: String,
     @SerializedName("nam") override val nameData: Dcc.NameData,
     @SerializedName("dob") override val dob: String,
     @SerializedName("v") override val payloads: List<VaccinationData>,
-) : Dcc<VaccinationDccV1.VaccinationData> {
+) : Dcc<VaccinationDccV1.VaccinationData>() {
 
     data class VaccinationData(
         // Disease or agent targeted, e.g. "tg": "840539006"
@@ -34,30 +33,10 @@ data class VaccinationDccV1(
         @SerializedName("is") override val certificateIssuer: String,
         // Unique Certificate Identifier, e.g.  "ci": "urn:uvci:01:NL:PlA8UWS60Z4RZXVALl6GAZ"
         @SerializedName("ci") override val uniqueCertificateIdentifier: String
-    ) : Dcc.Payload {
+    ) : Payload {
         // Can't use lazy because GSON will NULL it, as we have no no-args constructor
         private var vaccinatedAtCache: LocalDate? = null
         val vaccinatedAt: LocalDate
-            get() = vaccinatedAtCache ?: dt.toLocalDateLeniently().also {
-                vaccinatedAtCache = it
-            }
-    }
-
-    // Can't use lazy because GSON will NULL it, as we have no no-args constructor
-    private var dateOfBirthCache: LocalDate? = null
-    override val dateOfBirth: LocalDate
-        get() = dateOfBirthCache ?: dob.toLocalDateLeniently().also {
-            dateOfBirthCache = it
-        }
-}
-
-private fun String.toLocalDateLeniently(): LocalDate = try {
-    LocalDate.parse(this)
-} catch (e: Exception) {
-    Timber.w("Irregular date string: %s", this)
-    try {
-        DateTime.parse(this).toLocalDate()
-    } catch (giveUp: Exception) {
-        throw giveUp
+            get() = vaccinatedAtCache ?: dt.toLocalDateLeniently().also { vaccinatedAtCache = it }
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/covidcertificate/vaccination/core/VaccinationQrCodeTestData.java b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/covidcertificate/vaccination/core/VaccinationQrCodeTestData.java
index 449bb349b..a4aa6186f 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/covidcertificate/vaccination/core/VaccinationQrCodeTestData.java
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/covidcertificate/vaccination/core/VaccinationQrCodeTestData.java
@@ -15,4 +15,16 @@ public class VaccinationQrCodeTestData {
     static public String qrCodeSweden = "HC1:NCFOXN%TSMAHN-HVN8J7UQMJ4/3RZLH62V2G1PC9CMSRH+QKFNTAVD3B19*AJCBMF6.UCOMIN6R%E5BD7HG8CU6O8QGU68ORJSPAEQOIR+SPCVO.28DDQHQ1BW9XX7ZY7NTICZU1*8X/KQ96/-KKTCY73JC3KD3LWT HB3ZC64JX7JQ1LK$2965VMFD-48YI 3533LC4TZ0BR/S09T./0ZYTS P-$0R:67PPDFPVX1R270:6C$Q0R6EOMUF5LDCPF5RBQ746B46O1N646RM9AL5CBVW566LH 469/9-3AKI6%T6LEQ-P6UQK*%NH$RSC9FFFW+7H9N$W2JO2C6S3UJ92KEST.ZJ-8B ZJ83B 2TAAUZZ2LH2%EUBUJZ0KZPIR145%T0YIF0JEYI1DLNCK1627ACW-T%NSY18KT911GL.EHNTI+SB-5A-ARUQNFW$ 2:.NU6W/CU8WDTFVG:BG3JFCSAVH-4V:HP4$0/.D9OV-RM60R7Z3B8PXICK+L/S1P*O:FG";
     // vaccinatedAt: Irregular date string: 2021-05-29T15:31:00+02:00
     static public String qrCodePoland = "HC1:6BFOXN%TS3DH+M8.IAS0RTAN:2MCID:D42:O%CM9W48+5MOOP-I3Z58MJNC5FAPQHIZC4.OI1RM8ZA.A53XHMKN4NN3F85QNPZ0K8C$JCW0KK.A96UJBC.P2R9CZXIAHAPEDG8C5DL-9C.PDI9309D: C+8DV9CA$DPN0NTICZU80LZW4Z*AK.GNNVR*G0C7PHBO33/X086B QTVINMJJDG3AE3RK38FN:43JON$97*97:L32SJ.L78PJ/FJBINB/S7-SN2HOH03I31M3EG3J%4UZ2UI7Y6T4R2H4T8%K+-8*S2E6J1$48X2-36D-I/2DW9J0$9+Q6X46Q3QR$P2OIC0JBLI+USK3UBVTVIJM/I2OC8ALD-ILOVGKFWZ07Y4 CTZ/3+N0ZUIQJAZGA2:UG%UJMI:TU+MM0W5-R53W12XE2O14P3.2O55O:FA$VKN6HQK3OKPEON4QDN7T*.53%1/HVII9H2JS6VS%J*HBXUCY+TU5EBYL5%T3V79YG%Q90MURRHY5D6$NN6VAQI8OEH.5PQ2WJF";
+
+    // vaccination date (`dt`) without day (YYYY-MM)
+    static public String failVaccinatedAtWithoutDay1 = "HC1:6BF$70A90T9WTWGSLKC 4759S-JXYFZJU:6MFBBOF1*70HS8FN07LCF$KWY0LACGEED97TK0F90JPCT3E5JDLA7$Q6E464W5TG6..DX%DZJC6/DTZ9 QE5$CB$DA/DLPCG/DXJDIZAITA9IANB8-+9I3D5 C*KE*PDMPCG/D5 C5IA5N9KECTHGWJC0FDC:5AIA%G7X+AQB9746HS80:54IBQF60R6$A80X6S1BTYACG6M+9XG8KIAWNA91AY%67092L4WJCT3EHS8XJC$+DXJC9WENF6OF63W5VX6+EDXVET3E5$CSUE6O9NPCSW5F/DBWENWE4WEB$D% D3IA4W5646946%96X47.JCP9EJY8L/5M/5546.96D463KC.SC4KCD3DX47B46IL6646H*6Z/E5JD%96IA74R6646407O/EZKEZ96446156O98J41PL2X+2P4OJ1K2:M7GKVJIG%MZ1TC 8X:6UL2C88TL9T DNKP/W5Z3QZ3T/EQHU7A78$JD0TPGDVYW778PC48+ADC%93DHSIVL1";
+    // vaccination date (`dt`) without day (YYYY)
+    static public String failVaccinatedAtWithoutDayAndMonth = "HC1:6BFY70D90T9WTWGSLKC 4759S-JXYFZJU:6MFBBL/0*70HS8FN07LCN%KWY0LACOFED97TK0F90JPCT3E5JDLA7$Q6E464W5TG6..DX%DZJC6/DTZ9 QE5$CB$DA/DLPCG/DXJDIZAITA9IANB8-+9I3D5 C*KE*PDMPCG/D5 C5IA5N9KECTHGWJC0FDC:5AIA%G7X+AQB9746HS80:54IBQF60R6$A80X6S1BTYACG6M+9XG8KIAWNA91AY%67092L4WJCT3EHS8XJC$+DXJC6WENF6OF6%JC QE/IAYJC5LEW34U3ET7DXC9 QE-ED8%E.JCBECB1A-:8$96646AL60A60S6Q$D.UDRYA 96NF6L/5QW6307KQEPD09WEQDD+Q6TW6FA7C466KCN9E%961A6DL6FA7D46$PC5$CUZCY$5Y$527B//UUBM/WJ6$V8.K.XER%O K1 HR5+OA-M/BLRMAFHK7 N4DWV.8HTB9AB:WA/:10N9 1NM*HDFAUURPAI7C4M46$21D8O-YTEF5P F";
+
+    // German reference case
+    static public String passGermanReferenceCase = "HC1:6BF+70790T9WTWGSLKC 4759S-JXYFZJU:6MFBBRW1*70HS8FN07LCP+KWY0LACQHED97TK0F90JPCT3E5JDLA7$Q6E464W5TG6..DX%DZJC6/DTZ9 QE5$CB$DA/DLPCG/DXJDIZAITA9IANB8-+9I3D5 C*KE*PDMPCG/D5 C5IA5N9KECTHGWJC0FDC:5AIA%G7X+AQB9746HS80:54IBQF60R6$A80X6S1BTYACG6M+9XG8KIAWNA91AY%67092L4WJCT3EHS8XJC$+DXJCCWENF6OF63W5NW6WF6%JC QE/IAYJC5LEW34U3ET7DXC9 QE-ED8%E.JCBECB1A-:8$96646AL60A60S6Q$D.UDRYA 96NF6L/5QW6307KQEPD09WEQDD+Q6TW6FA7C466KCN9E%961A6DL6FA7D46$PC5$CUZCY$5Y$527B//UUBM/WJ6$V8.K.XER%O K1 HR5+OA-M/BLRMAFHK7 N4DWV.8HTB9AB:WA/:10N9 1NM*HDFAUURPAI7C4M46$21D8O-YTMK2*-F";
+    // dates (`dob` and `dt`) with time information at midnight
+    static public String passDatesWithTimeAtMidnight = "HC1:6BFOXN%TSMAHN-HVN8J7UQMJ4/36 L-AHZR61RO4.S-OPT-IAVCQN68WAYKS.-CF/8X*GBUP:TH$X44WFVR5VVBJZI+EBS7BBYIPZA 1JQEDK8C$2TB-DVQTLTCGJSR4UIIDG4UHT3/%DRWDJZIR9KQ+S9ZIHAPZXI+IA1VCSWC%PD*ZLV6CTJCB6C%*8ZJJY 84IJZJJ1W4*$I*NVPC1LJL4A7%83MKN4NN3F85QNCY0O%0 58:0LPHN6D7LLK*2HG%89UV-0LZ 2S-O:S9UZ4+FJE 4Y3LL/II 0OC9SX0+*B85T%62*5PZD5UE9T0H.3TCNNK1HM+8/AE:PI/E2$4JY/K :SOCJ1HC7%G5/2K$B64FEIA6LF2.GQ*GH988VGBPEH$P$CN583937VNOI34NL0HOJ ZJ83B6+2.H3//T 1JZ0K4LIET32KTN6E84UIXEJ7L78BUDBQEAJJKHHGWC8CVO5/H9QO$$OUBU%I75YC%HI: R*3ETE7WXE5WTOYU/MSH$17+NCDTC-J5MQ447$TUPDWSAN9+6Y6S5EODANF3A+YAY0EMFO+LMYCV$*006JB8J";
+    // vaccination date (`dt`) with real time information
+    static public String passDatesWithRealTimeInfo = "HC1:6BFS80J80T9WTWGSLKC 4759S-JXYFZJU:6MFBBF+5*70HS8FN07LCK.KWY0LACLJED97TK0F90$PC5$CUZCY$5Y$5TPCBEC7ZKW.CUEEY3EAECWGDMXG2QDUW5*MEWUMLPCG/DD3E01ALB81C9%NASB9MN951A JC6/DYOACEC+EDR/OLECMPCG/D8EDETAG+9*NAWB8JPCT3E5JDKA7Q47%964W5-A67:EDOL9WEQDD+Q6TW6FA7C466KCK9E2H9G:6V6BEM6Q$D.UDRYA 96OF6L/5SW6KB7B$D% D3IA4W5646946846.96XJC +D3KC.SCXJCQWEF83846Y969464W5K57.964G72A6646VK5XF6646WJCT3E 6A%JCXQEIN8G/D6LE ZDQZCAJB0LEE4F0ECOPCY8FHZA1+9LZAZM81G72A6 6A2G7O/5L*8U%61H8E46H%6SG8H%6M*8:G85S8D46DM8+A8BM8-Q68+8O98J41PL2X+2P4OJ1K2:M7GKVJIG%MZ1TC 8X:6UL2C88TL9T DNKP/W5Z3QZ3T/EQHU7A78$JD0TPGDVYW778PC48+ADC%9:DHWCJ55";
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/covidcertificate/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/covidcertificate/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt
index 9f3ff3579..d9a43ce77 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/covidcertificate/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/covidcertificate/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt
@@ -216,4 +216,57 @@ class VaccinationQRCodeExtractorTest : BaseTest() {
             mode = Mode.CERT_VAC_STRICT
         )
     }
+
+    @Test
+    fun `fail vaccinated at date without day`() {
+        shouldThrow<InvalidVaccinationCertificateException> {
+            extractor.extract(
+                VaccinationQrCodeTestData.failVaccinatedAtWithoutDay1,
+                mode = Mode.CERT_VAC_STRICT
+            )
+        }.errorCode shouldBe InvalidHealthCertificateException.ErrorCode.JSON_SCHEMA_INVALID
+    }
+
+    @Test
+    fun `fail vaccinated at date without day and month`() {
+        shouldThrow<InvalidVaccinationCertificateException> {
+            extractor.extract(
+                VaccinationQrCodeTestData.failVaccinatedAtWithoutDayAndMonth,
+                mode = Mode.CERT_VAC_STRICT
+            )
+        }.errorCode shouldBe InvalidHealthCertificateException.ErrorCode.JSON_SCHEMA_INVALID
+    }
+
+    @Test
+    fun `pass german reference case`() {
+        extractor.extract(
+            VaccinationQrCodeTestData.passGermanReferenceCase,
+            mode = Mode.CERT_VAC_STRICT
+        ).apply {
+            data.certificate.dateOfBirth shouldBe LocalDate.parse("1964-08-12")
+            data.certificate.payload.vaccinatedAt shouldBe LocalDate.parse("2021-05-29")
+        }
+    }
+
+    @Test
+    fun `pass vaccination and dob with time at midnight`() {
+        extractor.extract(
+            VaccinationQrCodeTestData.passDatesWithTimeAtMidnight,
+            mode = Mode.CERT_VAC_STRICT
+        ).apply {
+            data.certificate.dateOfBirth shouldBe LocalDate.parse("1978-01-26")
+            data.certificate.payload.vaccinatedAt shouldBe LocalDate.parse("2021-03-09")
+        }
+    }
+
+    @Test
+    fun `pass vaccination date with full timestamp`() {
+        extractor.extract(
+            VaccinationQrCodeTestData.passDatesWithRealTimeInfo,
+            mode = Mode.CERT_VAC_STRICT
+        ).apply {
+            data.certificate.dateOfBirth shouldBe LocalDate.parse("1958-11-11")
+            data.certificate.payload.vaccinatedAt shouldBe LocalDate.parse("2021-03-18")
+        }
+    }
 }
-- 
GitLab