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 8d8c4b030802099b01ee409d6f422c46f96b1c2c..52d13dd0873100170f356ef936efe1fee2af6456 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 c105167986ab66c556716285921ab8978fddef9b..3ebb7e072cf8b41edb5c1ef43aa64faaf0f016e5 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 e231e3ed67601d3b246d406059bea3260c50fb4d..5a163335cbbbe659fd9e43a46339c301e9bbc491 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 449bb349b7bde6a840af11ed0f3c40bf32b3981a..a4aa6186f7506191ad2dcf54979198b865ee8485 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 9f3ff3579b04520f4c52f1bf99654a2d1cb86eed..d9a43ce77ba7ba48a59d65ffd73965f89b9bc94c 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") + } + } }