From c1f334e6868855c616fa14f3c67eac2d19ef2a42 Mon Sep 17 00:00:00 2001 From: Chilja Gossow <49635654+chiljamgossow@users.noreply.github.com> Date: Mon, 19 Apr 2021 14:47:36 +0200 Subject: [PATCH] Extract data from QR codes (EXPOSUREAPP-6031) (#2843) * extract data from qr code * move to extractor * unit test pcr test * unit tests * unit tests * adapt for hash * resolve merge conflicts * make sure no unexpected exception is thrown in case a value is missing * use correct type * remove guid from supertype as RAT use a hsh instead * fix tests * add hex check * detekt Co-authored-by: Lukas Lechner <lukas.lechner@sap.com> --- .../risk/storage/DefaultRiskLevelStorage.kt | 2 +- .../coronatest/qrcode/CoronaTestQRCode.kt | 34 +++++-- .../qrcode/CoronaTestQRCodeValidation.kt | 12 --- .../qrcode/CoronaTestQRCodeValidator.kt | 27 ++++++ .../qrcode/InvalidQRCodeException.kt | 5 + .../coronatest/qrcode/PcrQrCodeExtractor.kt | 34 +++++++ .../qrcode/RapidAntigenQrCodeExtractor.kt | 92 +++++++++++++++++++ .../rapidantigen/RapidAntigenProcessor.kt | 2 +- .../service/submission/QRScanResult.kt | 28 ------ .../scan/SubmissionQRCodeScanViewModel.kt | 27 +++--- .../qrcode/CoronaTestQrCodeValidatorTest.kt | 32 +++++++ .../qrcode/PcrQrCodeExtractorTest.kt} | 50 +++++----- .../qrcode/RapidAntigenQrCodeExtractorTest.kt | 45 +++++++++ .../coronatest/qrcode/TestQrCodes.kt | 22 +++++ .../scan/SubmissionQRCodeScanViewModelTest.kt | 38 +++++++- 15 files changed, 352 insertions(+), 98 deletions(-) delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidation.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidator.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/InvalidQRCodeException.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractor.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractor.kt delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/QRScanResult.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQrCodeValidatorTest.kt rename Corona-Warn-App/src/test/java/de/rki/coronawarnapp/{service/submission/ScanResultTest.kt => coronatest/qrcode/PcrQrCodeExtractorTest.kt} (61%) create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractorTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/TestQrCodes.kt diff --git a/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt b/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt index 92111156c..0ab2b436c 100644 --- a/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt +++ b/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt @@ -27,7 +27,7 @@ class DefaultRiskLevelStorage @Inject constructor( // Taken from TimeVariables.MAX_STALE_EXPOSURE_RISK_RANGE override val storedResultLimit: Int = 2 * 6 - override suspend fun storeExposureWindows(storedResultId: String, result: EwRiskLevelResult) { + override suspend fun storeExposureWindows(storedResultId: String, resultEw: EwRiskLevelResult) { Timber.d("storeExposureWindows(): NOOP") // NOOP } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCode.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCode.kt index 5f7a2fb6e..0db2d0ed6 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCode.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCode.kt @@ -3,6 +3,7 @@ package de.rki.coronawarnapp.coronatest.qrcode import android.os.Parcelable import de.rki.coronawarnapp.coronatest.TestRegistrationRequest import de.rki.coronawarnapp.coronatest.type.CoronaTest +import de.rki.coronawarnapp.util.HashExtensions.toSHA256 import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.joda.time.Instant @@ -11,31 +12,46 @@ import org.joda.time.LocalDate sealed class CoronaTestQRCode : Parcelable, TestRegistrationRequest { abstract override val type: CoronaTest.Type - abstract val qrCodeGUID: CoronaTestGUID - - @IgnoredOnParcel - override val identifier: String - get() = "qrcode-${type.raw}-$qrCodeGUID" + abstract val registrationIdentifier: String @Parcelize data class PCR( - override val qrCodeGUID: CoronaTestGUID, + val qrCodeGUID: CoronaTestGUID, ) : CoronaTestQRCode() { - @IgnoredOnParcel override val type: CoronaTest.Type = CoronaTest.Type.PCR + @IgnoredOnParcel + override val type: CoronaTest.Type = CoronaTest.Type.PCR + + @IgnoredOnParcel + override val identifier: String + get() = "qrcode-${type.raw}-$qrCodeGUID" + + @IgnoredOnParcel + override val registrationIdentifier: String + get() = qrCodeGUID } @Parcelize data class RapidAntigen( - override val qrCodeGUID: CoronaTestGUID, + val hash: RapidAntigenHash, val createdAt: Instant, val firstName: String?, val lastName: String?, val dateOfBirth: LocalDate?, ) : CoronaTestQRCode() { - @IgnoredOnParcel override val type: CoronaTest.Type = CoronaTest.Type.RAPID_ANTIGEN + @IgnoredOnParcel + override val type: CoronaTest.Type = CoronaTest.Type.RAPID_ANTIGEN + + @IgnoredOnParcel + override val identifier: String + get() = "qrcode-${type.raw}-$hash" + + @IgnoredOnParcel + override val registrationIdentifier: String + get() = hash.toSHA256() } } typealias CoronaTestGUID = String +typealias RapidAntigenHash = String diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidation.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidation.kt deleted file mode 100644 index 717aaf783..000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidation.kt +++ /dev/null @@ -1,12 +0,0 @@ -package de.rki.coronawarnapp.coronatest.qrcode - -import dagger.Reusable -import javax.inject.Inject - -@Reusable -class CoronaTestQRCodeValidation @Inject constructor() { - - suspend fun validate(qrCode: String): CoronaTestQRCode { - throw NotImplementedError() - } -} 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 new file mode 100644 index 000000000..a3a54f940 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidator.kt @@ -0,0 +1,27 @@ +package de.rki.coronawarnapp.coronatest.qrcode + +import dagger.Reusable +import timber.log.Timber +import javax.inject.Inject + +@Reusable +class CoronaTestQrCodeValidator @Inject constructor( + ratExtractor: RapidAntigenQrCodeExtractor, + pcrExtractor: PcrQrCodeExtractor +) { + + private val extractors = setOf(ratExtractor, pcrExtractor) + + fun validate(rawString: String): CoronaTestQRCode { + return extractors + .find { it.canHandle(rawString) } + ?.extract(rawString) + ?.also { Timber.i("Extracted data from QR code is $it") } + ?: throw InvalidQRCodeException() + } +} + +interface QrCodeExtractor { + fun canHandle(rawString: String): Boolean + fun extract(rawString: String): CoronaTestQRCode +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/InvalidQRCodeException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/InvalidQRCodeException.kt new file mode 100644 index 000000000..24dd420a3 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/InvalidQRCodeException.kt @@ -0,0 +1,5 @@ +package de.rki.coronawarnapp.coronatest.qrcode + +open class InvalidQRCodeException( + message: String = "An error occurred while parsing the qr code" +) : Exception(message) 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 new file mode 100644 index 000000000..924bd4e89 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractor.kt @@ -0,0 +1,34 @@ +package de.rki.coronawarnapp.coronatest.qrcode + +import java.util.regex.Pattern +import javax.inject.Inject + +class PcrQrCodeExtractor @Inject constructor() : QrCodeExtractor { + + override fun canHandle(rawString: String): Boolean = rawString.startsWith(prefix, ignoreCase = true) + + override fun extract(rawString: String): CoronaTestQRCode.PCR { + return CoronaTestQRCode.PCR( + extractGUID(rawString) + ) + } + + private fun extractGUID(rawString: String): CoronaTestGUID { + if (!pattern.toRegex().matches(rawString)) throw InvalidQRCodeException() + + val matcher = pattern.matcher(rawString) + return if (matcher.matches()) { + matcher.group(1) as CoronaTestGUID + } else throw InvalidQRCodeException() + } + + private val prefix: String = "https://localhost" + + private val pattern: Pattern = ( + "^" + // Match start of string + "(?:https:\\/{2}localhost)" + // Match `https://localhost` + "(?:\\/{1}\\?)" + // Match the query param `/?` + "([a-f\\d]{6}[-][a-f\\d]{8}[-](?:[a-f\\d]{4}[-]){3}[a-f\\d]{12})" + // Match the UUID + "\$" + ).toPattern(Pattern.CASE_INSENSITIVE) +} 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 new file mode 100644 index 000000000..deffb83ef --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractor.kt @@ -0,0 +1,92 @@ +package de.rki.coronawarnapp.coronatest.qrcode + +import com.google.common.io.BaseEncoding +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import de.rki.coronawarnapp.util.serialization.fromJson +import okio.internal.commonToUtf8String +import org.joda.time.Instant +import org.joda.time.LocalDate +import timber.log.Timber +import java.util.regex.Matcher +import java.util.regex.Pattern +import javax.inject.Inject + +class RapidAntigenQrCodeExtractor @Inject constructor() : QrCodeExtractor { + + private val prefix: String = "https://s.coronawarn.app?v=1#" + private val prefix2: String = "https://s.coronawarn.app/?v=1#" + private val hexPattern: Pattern = Pattern.compile("\\p{XDigit}+") + + override fun canHandle(rawString: String): Boolean { + return rawString.startsWith(prefix, ignoreCase = true) || rawString.startsWith(prefix2, ignoreCase = true) + } + + override fun extract(rawString: String): CoronaTestQRCode.RapidAntigen { + val data = extractData(rawString).validate() + return CoronaTestQRCode.RapidAntigen( + hash = data.hash!!, + createdAt = data.createdAt!!, + firstName = data.firstName, + lastName = data.lastName, + dateOfBirth = data.dateOfBirth + ) + } + + private fun Payload.validate(): Payload { + if (hash == null || !hash.isSha256Hash()) throw InvalidQRCodeException("Hash is invalid") + if (timestamp == null || timestamp <= 0) throw InvalidQRCodeException("Timestamp is invalid") + createdAt = Instant.ofEpochSecond(timestamp) + dateOfBirth = dob?.let { + try { + LocalDate.parse(it) + } catch (e: Exception) { + Timber.e("Invalid date format") + throw InvalidQRCodeException("Date of birth has wrong format: $it. It should be YYYY-MM-DD") + } + } + return this + } + + private fun String.isSha256Hash(): Boolean { + return length == 64 && isHexadecimal() + } + + private fun String.isHexadecimal(): Boolean { + val matcher: Matcher = hexPattern.matcher(this) + return matcher.matches() + } + + private fun extractData(rawString: String): Payload { + return rawString + .removePrefix(prefix) + .removePrefix(prefix2) + .decode() + } + + private fun String.decode(): Payload { + val decoded = if ( + this.contains("+") || + this.contains("/") || + this.contains("=") + ) { + BaseEncoding.base64().decode(this) + } else { + BaseEncoding.base64Url().decode(this) + } + return Gson().fromJson(decoded.commonToUtf8String()) + } + + private data class Payload( + val hash: String?, + val timestamp: Long?, + @SerializedName("fn") + val firstName: String?, + @SerializedName("ln") + val lastName: String?, + val dob: String? + ) { + var dateOfBirth: LocalDate? = null + var createdAt: Instant? = null + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessor.kt index 424b7eda6..6052fa0a0 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessor.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessor.kt @@ -26,7 +26,7 @@ class RapidAntigenProcessor @Inject constructor( Timber.tag(TAG).d("create(data=%s)", request) request as CoronaTestQRCode.RapidAntigen - val registrationData = submissionService.asyncRegisterDeviceViaGUID(request.qrCodeGUID) + val registrationData = submissionService.asyncRegisterDeviceViaGUID(request.registrationIdentifier) registrationData.testResult.validOrThrow() diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/QRScanResult.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/QRScanResult.kt deleted file mode 100644 index 2a054d0eb..000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/QRScanResult.kt +++ /dev/null @@ -1,28 +0,0 @@ -package de.rki.coronawarnapp.service.submission - -import java.util.regex.Pattern - -data class QRScanResult(val rawResult: String) { - - val isValid: Boolean - get() = guid != null - val guid: String? by lazy { extractGUID(rawResult) } - - private fun extractGUID(rawResult: String): String? { - if (!QR_CODE_REGEX.toRegex().matches(rawResult)) return null - - val matcher = QR_CODE_REGEX.matcher(rawResult) - return if (matcher.matches()) matcher.group(1) else null - } - - companion object { - // regex pattern for scanned QR code URL - val QR_CODE_REGEX: Pattern = ( - "^" + // Match start of string - "(?:https:\\/{2}localhost)" + // Match `https://localhost` - "(?:\\/{1}\\?)" + // Match the query param `/?` - "([a-f\\d]{6}[-][a-f\\d]{8}[-](?:[a-f\\d]{4}[-]){3}[a-f\\d]{12})" + // Match the UUID - "\$" - ).toPattern(Pattern.CASE_INSENSITIVE) - } -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt index 5bd0b24a2..ff300dd09 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt @@ -6,13 +6,14 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import de.rki.coronawarnapp.bugreporting.censors.QRCodeCensor import de.rki.coronawarnapp.coronatest.qrcode.CoronaTestQRCode +import de.rki.coronawarnapp.coronatest.qrcode.CoronaTestQrCodeValidator +import de.rki.coronawarnapp.coronatest.qrcode.InvalidQRCodeException import de.rki.coronawarnapp.coronatest.server.CoronaTestResult import de.rki.coronawarnapp.coronatest.type.CoronaTest import de.rki.coronawarnapp.exception.ExceptionCategory import de.rki.coronawarnapp.exception.TransactionException import de.rki.coronawarnapp.exception.http.CwaWebException import de.rki.coronawarnapp.exception.reporting.report -import de.rki.coronawarnapp.service.submission.QRScanResult import de.rki.coronawarnapp.submission.SubmissionRepository import de.rki.coronawarnapp.ui.submission.ApiRequestState import de.rki.coronawarnapp.ui.submission.ScanStatus @@ -25,21 +26,21 @@ import timber.log.Timber class SubmissionQRCodeScanViewModel @AssistedInject constructor( private val submissionRepository: SubmissionRepository, - private val cameraSettings: CameraSettings + private val cameraSettings: CameraSettings, + private val qrCodeValidator: CoronaTestQrCodeValidator ) : CWAViewModel() { val routeToScreen = SingleLiveEvent<SubmissionNavigationEvents>() val showRedeemedTokenWarning = SingleLiveEvent<Unit>() val scanStatusValue = SingleLiveEvent<ScanStatus>() - open class InvalidQRCodeException : Exception("error in qr code") - fun validateTestGUID(rawResult: String) { - val scanResult = QRScanResult(rawResult) - if (scanResult.isValid) { - QRCodeCensor.lastGUID = scanResult.guid + try { + val coronaTestQRCode = qrCodeValidator.validate(rawResult) + // TODO this needs to be adapted to work for different types + QRCodeCensor.lastGUID = coronaTestQRCode.registrationIdentifier scanStatusValue.postValue(ScanStatus.SUCCESS) - doDeviceRegistration(scanResult) - } else { + doDeviceRegistration(coronaTestQRCode) + } catch (err: InvalidQRCodeException) { scanStatusValue.postValue(ScanStatus.INVALID) } } @@ -53,13 +54,11 @@ class SubmissionQRCodeScanViewModel @AssistedInject constructor( ) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun doDeviceRegistration(scanResult: QRScanResult) = launch { + internal fun doDeviceRegistration(coronaTestQRCode: CoronaTestQRCode) = launch { try { registrationState.postValue(RegistrationState(ApiRequestState.STARTED)) - val request = CoronaTestQRCode.PCR(qrCodeGUID = scanResult.guid!!) - val coronaTest = submissionRepository.registerTest(request) - // TODO this needs to depend on what the user selected - submissionRepository.giveConsentToSubmission(type = CoronaTest.Type.PCR) + val coronaTest = submissionRepository.registerTest(coronaTestQRCode) + submissionRepository.giveConsentToSubmission(type = coronaTestQRCode.type) checkTestResult(coronaTest.testResult) registrationState.postValue(RegistrationState(ApiRequestState.SUCCESS, coronaTest.testResult)) } catch (err: CwaWebException) { 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 new file mode 100644 index 000000000..2d9478b79 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQrCodeValidatorTest.kt @@ -0,0 +1,32 @@ +package de.rki.coronawarnapp.coronatest.qrcode + +import de.rki.coronawarnapp.coronatest.type.CoronaTest +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class CoronaTestQrCodeValidatorTest : BaseTest() { + + @Test + fun `valid codes are extracted by corresponding extractor`() { + val instance = CoronaTestQrCodeValidator(RapidAntigenQrCodeExtractor(), PcrQrCodeExtractor()) + instance.validate(pcrQrCode1).type shouldBe CoronaTest.Type.PCR + instance.validate(pcrQrCode2).type shouldBe CoronaTest.Type.PCR + instance.validate(pcrQrCode3).type shouldBe CoronaTest.Type.PCR + instance.validate(raQrCode1).type shouldBe CoronaTest.Type.RAPID_ANTIGEN + instance.validate(raQrCode2).type shouldBe CoronaTest.Type.RAPID_ANTIGEN + instance.validate(raQrCode3).type shouldBe CoronaTest.Type.RAPID_ANTIGEN + } + + @Test + fun `invalid code throws exception`() { + val invalidCode = "HTTPS://somethingelse/?123456-12345678-1234-4DA7-B166-B86D85475064" + val instance = CoronaTestQrCodeValidator(RapidAntigenQrCodeExtractor(), PcrQrCodeExtractor()) + return try { + instance.validate(invalidCode) + false + } catch (e: InvalidQRCodeException) { + true + } shouldBe true + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/ScanResultTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractorTest.kt similarity index 61% rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/ScanResultTest.kt rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractorTest.kt index 44bf60cc1..e4537ff5a 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/ScanResultTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractorTest.kt @@ -1,15 +1,10 @@ -package de.rki.coronawarnapp.service.submission +package de.rki.coronawarnapp.coronatest.qrcode import io.kotest.matchers.shouldBe -import io.mockk.MockKAnnotations -import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.mockkObject -import org.junit.Before import org.junit.Test import testhelpers.BaseTest -class ScanResultTest : BaseTest() { +class PcrQrCodeExtractorTest : BaseTest() { private val guidUpperCase = "123456-12345678-1234-4DA7-B166-B86D85475064" private val guidLowerCase = "123456-12345678-1234-4da7-b166-b86d85475064" private val guidMixedCase = "123456-12345678-1234-4dA7-b166-B86d85475064" @@ -17,19 +12,18 @@ class ScanResultTest : BaseTest() { private val localhostLowerCase = "https://localhost/?" private val localhostMixedCase = "https://LOCALHOST/?" - @MockK - private lateinit var scanResult: QRScanResult - - @Before - fun setUp() { - MockKAnnotations.init(this) - mockkObject(scanResult) - every { scanResult.isValid } returns false - } - private fun buildQRCodeCases(prefixString: String, guid: String, conditionToMatch: Boolean) { - scanResult = QRScanResult("$prefixString$guid") - scanResult.isValid shouldBe conditionToMatch + val extractor = PcrQrCodeExtractor() + try { + if (extractor.canHandle("$prefixString$guid")) { + extractor.extract("$prefixString$guid") + conditionToMatch shouldBe true + } else { + conditionToMatch shouldBe false + } + } catch (e: InvalidQRCodeException) { + conditionToMatch shouldBe false + } } @Test @@ -83,16 +77,16 @@ class ScanResultTest : BaseTest() { @Test fun extractGUID() { - QRScanResult("$localhostUpperCase$guidUpperCase").guid shouldBe guidUpperCase - QRScanResult("$localhostUpperCase$guidLowerCase").guid shouldBe guidLowerCase - QRScanResult("$localhostUpperCase$guidMixedCase").guid shouldBe guidMixedCase + PcrQrCodeExtractor().extract("$localhostUpperCase$guidUpperCase").qrCodeGUID shouldBe guidUpperCase + PcrQrCodeExtractor().extract("$localhostUpperCase$guidLowerCase").qrCodeGUID shouldBe guidLowerCase + PcrQrCodeExtractor().extract("$localhostUpperCase$guidMixedCase").qrCodeGUID shouldBe guidMixedCase - QRScanResult("$localhostLowerCase$guidUpperCase").guid shouldBe guidUpperCase - QRScanResult("$localhostLowerCase$guidLowerCase").guid shouldBe guidLowerCase - QRScanResult("$localhostLowerCase$guidMixedCase").guid shouldBe guidMixedCase + PcrQrCodeExtractor().extract("$localhostLowerCase$guidUpperCase").qrCodeGUID shouldBe guidUpperCase + PcrQrCodeExtractor().extract("$localhostLowerCase$guidLowerCase").qrCodeGUID shouldBe guidLowerCase + PcrQrCodeExtractor().extract("$localhostLowerCase$guidMixedCase").qrCodeGUID shouldBe guidMixedCase - QRScanResult("$localhostMixedCase$guidUpperCase").guid shouldBe guidUpperCase - QRScanResult("$localhostMixedCase$guidLowerCase").guid shouldBe guidLowerCase - QRScanResult("$localhostMixedCase$guidMixedCase").guid shouldBe guidMixedCase + PcrQrCodeExtractor().extract("$localhostMixedCase$guidUpperCase").qrCodeGUID shouldBe guidUpperCase + PcrQrCodeExtractor().extract("$localhostMixedCase$guidLowerCase").qrCodeGUID shouldBe guidLowerCase + PcrQrCodeExtractor().extract("$localhostMixedCase$guidMixedCase").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 new file mode 100644 index 000000000..ac2fd0474 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractorTest.kt @@ -0,0 +1,45 @@ +package de.rki.coronawarnapp.coronatest.qrcode + +import de.rki.coronawarnapp.coronatest.type.CoronaTest +import io.kotest.matchers.shouldBe +import org.joda.time.Instant +import org.joda.time.LocalDate +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class RapidAntigenQrCodeExtractorTest : BaseTest() { + + private val instance = RapidAntigenQrCodeExtractor() + + @Test + fun `valid codes are recognized`() { + listOf(raQrCode1, raQrCode2, raQrCode3, raQrCode4, raQrCode5, raQrCode6, raQrCode7, raQrCode8).forEach { + instance.canHandle(it) shouldBe true + } + } + + @Test + fun `invalid codes are rejected`() { + listOf(pcrQrCode1, pcrQrCode2, pcrQrCode3).forEach { + instance.canHandle(it) shouldBe false + } + } + + @Test + fun `extracting valid codes does not throw exception`() { + listOf(raQrCode1, raQrCode2, raQrCode3, raQrCode4, raQrCode5, raQrCode6, raQrCode7, raQrCode8).forEach { + instance.extract(it) + } + } + + @Test + fun `personal data is extracted`() { + val data = instance.extract(raQrCode3) + data.type shouldBe CoronaTest.Type.RAPID_ANTIGEN + data.hash shouldBe "7b1c063e883063f8c33ffaa256aded506afd907f7446143b3da0f938a21967a9" + data.createdAt shouldBe Instant.ofEpochMilli(1618563782000) + data.dateOfBirth shouldBe LocalDate.parse("1962-01-08") + data.lastName shouldBe "Hayes" + data.firstName shouldBe "Alma" + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/TestQrCodes.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/TestQrCodes.kt new file mode 100644 index 000000000..d8ebbe36e --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/TestQrCodes.kt @@ -0,0 +1,22 @@ +package de.rki.coronawarnapp.coronatest.qrcode + +internal val pcrQrCode1 = "HTTPS://LOCALHOST/?123456-12345678-1234-4DA7-B166-B86D85475064" +internal val pcrQrCode2 = "https://localhost/?123456-12345678-1234-4DA7-B166-B86D85475064" +internal val pcrQrCode3 = "https://LOCALHOST/?123456-12345678-1234-4DA7-B166-B86D85475064" + +internal val raQrCode1 = + "https://s.coronawarn.app/?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjA5NjQsInNhbHQiOiIwQ0ZEMUJCQzI2Q0FCODVCNkZFNDE5MTJFQjFBQUU1QUNEN0QyNjA0RTQwNTQyRUVEQjZEQUYyQkRBMDQ5QzRGIiwidGVzdElkIjoiYjM2YzUzN2ItZWQ5NC00Njc3LTkzZmQtODUwMTY4NjlkYjEwIiwiaGFzaCI6IjJiNTc0NjhlN2Q4MTkyMWQzOGM4OGI1NjExOWE0Y2ViMzYyNmI1MDM4ZWI5Njk3ZjkxOTQ4NmJjMzg0Y2U2M2UifQ" +internal val raQrCode2 = + "https://s.coronawarn.app?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjA5NjQsInNhbHQiOiIwQ0ZEMUJCQzI2Q0FCODVCNkZFNDE5MTJFQjFBQUU1QUNEN0QyNjA0RTQwNTQyRUVEQjZEQUYyQkRBMDQ5QzRGIiwidGVzdElkIjoiYjM2YzUzN2ItZWQ5NC00Njc3LTkzZmQtODUwMTY4NjlkYjEwIiwiaGFzaCI6IjJiNTc0NjhlN2Q4MTkyMWQzOGM4OGI1NjExOWE0Y2ViMzYyNmI1MDM4ZWI5Njk3ZjkxOTQ4NmJjMzg0Y2U2M2UifQ" +internal val raQrCode3 = + "https://s.coronawarn.app/?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjM3ODIsInNhbHQiOiI1QTI3M0REREJCQTFEMkFDQUEzN0ExMDg4NjhGNkIwMjM3NjQzRjhBNjdCQTNENkQ3RUE3RkREQ0M0RDJGMjBEIiwidGVzdElkIjoiMGQ5ZTg0MzItZWI5MS00YzhmLTgyYWYtNWEwMWZiMWI2NzYwIiwiaGFzaCI6IjdiMWMwNjNlODgzMDYzZjhjMzNmZmFhMjU2YWRlZDUwNmFmZDkwN2Y3NDQ2MTQzYjNkYTBmOTM4YTIxOTY3YTkiLCJmbiI6IkFsbWEiLCJsbiI6IkhheWVzIiwiZG9iIjoiMTk2Mi0wMS0wOCJ9" +internal val raQrCode4 = + "https://s.coronawarn.app?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjM3ODIsInNhbHQiOiI1QTI3M0REREJCQTFEMkFDQUEzN0ExMDg4NjhGNkIwMjM3NjQzRjhBNjdCQTNENkQ3RUE3RkREQ0M0RDJGMjBEIiwidGVzdElkIjoiMGQ5ZTg0MzItZWI5MS00YzhmLTgyYWYtNWEwMWZiMWI2NzYwIiwiaGFzaCI6IjdiMWMwNjNlODgzMDYzZjhjMzNmZmFhMjU2YWRlZDUwNmFmZDkwN2Y3NDQ2MTQzYjNkYTBmOTM4YTIxOTY3YTkiLCJmbiI6IkFsbWEiLCJsbiI6IkhheWVzIiwiZG9iIjoiMTk2Mi0wMS0wOCJ9" +internal val raQrCode5 = + "https://s.coronawarn.app/?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjM4NDMsInNhbHQiOiJBNkVFRkZERDE2Qzk5RDFCODEyREE2NTc1NzgwMDM1QTQ0REI0OUM5NTBGODdCQkY0NDJBRkIwMDE5NkZCNDQ4IiwidGVzdElkIjoiNDhjOTc2ODgtY2U2ZC00MDFjLWEwMmMtMjU5MTE2YTRmODhmIiwiaGFzaCI6IjIyMTA4Y2FmNTQwNWM0MzI5Y2I3ZTEzNzg3MTMxMDJhMGNkNjY4OWM3YWFmMWJjOGViYzk3MDdiMjNjNTZhN2UifQ==" +internal val raQrCode6 = + "https://s.coronawarn.app?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjM4NDMsInNhbHQiOiJBNkVFRkZERDE2Qzk5RDFCODEyREE2NTc1NzgwMDM1QTQ0REI0OUM5NTBGODdCQkY0NDJBRkIwMDE5NkZCNDQ4IiwidGVzdElkIjoiNDhjOTc2ODgtY2U2ZC00MDFjLWEwMmMtMjU5MTE2YTRmODhmIiwiaGFzaCI6IjIyMTA4Y2FmNTQwNWM0MzI5Y2I3ZTEzNzg3MTMxMDJhMGNkNjY4OWM3YWFmMWJjOGViYzk3MDdiMjNjNTZhN2UifQ==" +internal val raQrCode7 = + "https://s.coronawarn.app/?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjM5MDIsInNhbHQiOiJGRkVERDZCMEM5MzZGRDVBQTg1M0NBNjgzOTY1QzYzMDI1NDRBMTIyNTY5MDAyQjg5OEI1NkM5OTY1NjRENTNGIiwidGVzdElkIjoiODYyNDcwYzItMzk3MS00M2Y0LWFjY2UtMjIzMzRlMjZiZTg3IiwiaGFzaCI6Ijc1ZmU5M2MyOWI1ZDI4NTU3NmJjZmM5NTZmYWIxMzllNTgxMWNhZWY1MTNiN2Y4MzhmNjY3NWJhYTU4MGM5YWUiLCJmbiI6IkJyeWFuIiwibG4iOiJNYXJzaCIsImRvYiI6IjE5OTAtMDQtMjgifQ==" +internal val raQrCode8 = + "https://s.coronawarn.app?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjM5MDIsInNhbHQiOiJGRkVERDZCMEM5MzZGRDVBQTg1M0NBNjgzOTY1QzYzMDI1NDRBMTIyNTY5MDAyQjg5OEI1NkM5OTY1NjRENTNGIiwidGVzdElkIjoiODYyNDcwYzItMzk3MS00M2Y0LWFjY2UtMjIzMzRlMjZiZTg3IiwiaGFzaCI6Ijc1ZmU5M2MyOWI1ZDI4NTU3NmJjZmM5NTZmYWIxMzllNTgxMWNhZWY1MTNiN2Y4MzhmNjY3NWJhYTU4MGM5YWUiLCJmbiI6IkJyeWFuIiwibG4iOiJNYXJzaCIsImRvYiI6IjE5OTAtMDQtMjgifQ==" diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt index ecaa78f82..02ed331db 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt @@ -1,13 +1,19 @@ package de.rki.coronawarnapp.ui.submission.qrcode.scan import de.rki.coronawarnapp.bugreporting.censors.QRCodeCensor +import de.rki.coronawarnapp.coronatest.qrcode.CoronaTestQRCode +import de.rki.coronawarnapp.coronatest.qrcode.CoronaTestQrCodeValidator +import de.rki.coronawarnapp.coronatest.qrcode.InvalidQRCodeException +import de.rki.coronawarnapp.coronatest.type.CoronaTest import de.rki.coronawarnapp.submission.SubmissionRepository import de.rki.coronawarnapp.ui.submission.ScanStatus import de.rki.coronawarnapp.util.permission.CameraSettings import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations +import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.mockk import io.mockk.verify import org.junit.Assert import org.junit.jupiter.api.BeforeEach @@ -22,6 +28,7 @@ class SubmissionQRCodeScanViewModelTest : BaseTest() { @MockK lateinit var submissionRepository: SubmissionRepository @MockK lateinit var cameraSettings: CameraSettings + @MockK lateinit var qrCodeValidator: CoronaTestQrCodeValidator @BeforeEach fun setUp() { @@ -30,11 +37,23 @@ class SubmissionQRCodeScanViewModelTest : BaseTest() { private fun createViewModel() = SubmissionQRCodeScanViewModel( submissionRepository, - cameraSettings + cameraSettings, + qrCodeValidator ) @Test fun scanStatusValid() { + // valid guid + val guid = "123456-12345678-1234-4DA7-B166-B86D85475064" + val coronaTestQRCode = CoronaTestQRCode.PCR( + qrCodeGUID = guid + ) + + val validQrCode = "https://localhost/?$guid" + val invalidQrCode = "https://no-guid-here" + + every { qrCodeValidator.validate(validQrCode) } returns coronaTestQRCode + every { qrCodeValidator.validate(invalidQrCode) } throws InvalidQRCodeException() val viewModel = createViewModel() // start @@ -44,17 +63,26 @@ class SubmissionQRCodeScanViewModelTest : BaseTest() { QRCodeCensor.lastGUID = null - // valid guid - val guid = "123456-12345678-1234-4DA7-B166-B86D85475064" - viewModel.validateTestGUID("https://localhost/?$guid") + viewModel.validateTestGUID(validQrCode) viewModel.scanStatusValue.let { Assert.assertEquals(ScanStatus.SUCCESS, it.value) } QRCodeCensor.lastGUID = guid // invalid guid - viewModel.validateTestGUID("https://no-guid-here") + viewModel.validateTestGUID(invalidQrCode) viewModel.scanStatusValue.let { Assert.assertEquals(ScanStatus.INVALID, it.value) } } + @Test + fun `doDeviceRegistration calls TestResultDataCollector`() { + val viewModel = createViewModel() + val mockResult = mockk<CoronaTestQRCode>().apply { + every { registrationIdentifier } returns "guid" + } + val mockTest = mockk<CoronaTest>() + coEvery { submissionRepository.registerTest(any()) } returns mockTest + viewModel.doDeviceRegistration(mockResult) + } + @Test fun `Camera settings is saved when user denies it`() { every { cameraSettings.isCameraDeniedPermanently } returns mockFlowPreference(false) -- GitLab