From cfb93e54dd8f6f9e99a3675f3c9fdd5f7cc1b501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gst=C3=B6hl?= Date: Thu, 14 Apr 2022 14:18:26 +0200 Subject: [PATCH] Implement downloading of ABN certs for testing --- wallet/build.gradle | 9 +- .../bag/covidcertificate/wallet/CertTest.kt | 62 +++++ .../bag/covidcertificate/wallet/CertUtil.kt | 214 ++++++++++++++++++ .../covidcertificate/wallet/EspressoUtil.kt | 12 + 4 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 wallet/src/androidTest/java/ch/admin/bag/covidcertificate/wallet/CertTest.kt create mode 100644 wallet/src/androidTest/java/ch/admin/bag/covidcertificate/wallet/CertUtil.kt diff --git a/wallet/build.gradle b/wallet/build.gradle index dbe9b5b2..b202b6da 100644 --- a/wallet/build.gradle +++ b/wallet/build.gradle @@ -48,6 +48,10 @@ android { buildConfigField "long", "BUILD_TIME", readPropertyWithDefault('buildTimestamp', System.currentTimeMillis()) + 'L' + buildConfigField "String", "OTP", readPropertyWithDefault('testing.otp', '""') + buildConfigField "String", "KEYSTORE", readPropertyWithDefault('testing.keystore', '""') + buildConfigField "String", "KEYSTORE_PASS", readPropertyWithDefault('testing.keystore-pass', '""') + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // The following argument makes the Android Test Orchestrator run its // "pm clear" command after each test invocation. This command ensures @@ -134,8 +138,8 @@ sonarqube { } dependencies { - androidTestImplementation 'androidx.test:rules:1.4.1-alpha03' - androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.0-alpha03' + androidTestImplementation 'androidx.test:rules:1.4.1-alpha05' + androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.0-alpha05' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' implementation 'com.google.android.material:material:1.5.0' @@ -160,6 +164,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0' androidTestImplementation 'androidx.test:runner:1.4.0' androidTestUtil 'androidx.test:orchestrator:1.4.1' } \ No newline at end of file diff --git a/wallet/src/androidTest/java/ch/admin/bag/covidcertificate/wallet/CertTest.kt b/wallet/src/androidTest/java/ch/admin/bag/covidcertificate/wallet/CertTest.kt new file mode 100644 index 00000000..ef342f9b --- /dev/null +++ b/wallet/src/androidTest/java/ch/admin/bag/covidcertificate/wallet/CertTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 Ubique Innovation AG + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package ch.admin.bag.covidcertificate.wallet + +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onData +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.ViewPagerActions +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.rule.ActivityTestRule +import androidx.test.runner.AndroidJUnit4 +import ch.admin.bag.covidcertificate.common.browserstack.Normal +import ch.admin.bag.covidcertificate.common.browserstack.Onboarding +import ch.admin.bag.covidcertificate.sdk.core.models.state.SuccessState +import ch.admin.bag.covidcertificate.sdk.core.models.state.VerificationState +import ch.admin.bag.covidcertificate.wallet.homescreen.pager.WalletItem +import org.hamcrest.Matchers +import org.hamcrest.Matchers.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.* + + +private val PFIZER = "EU/1/20/1507" +private val DAYS = 24 * 60 * 60L + +@Normal +@RunWith(AndroidJUnit4::class) +class CertTest : EspressoUtil() { + @Rule + @JvmField + var mActivityTestRule = ActivityTestRule(MainActivity::class.java) + + @Test + fun testCertDownload() { + Intents.init() + doOnboarding() + + val now = Instant.now() + + val uri = downloadVaccineCert(PFIZER, 2,2, Date.from(now.minusMillis(365 * DAYS))) + importCert(uri) + checkCertValidity(UiValidityState.EXPIRED) + importCert(downloadVaccineCert(PFIZER, 3, 3, Date.from(now.minusMillis(1 * DAYS)))) + checkCertValidity(UiValidityState.VALID) + } + +} diff --git a/wallet/src/androidTest/java/ch/admin/bag/covidcertificate/wallet/CertUtil.kt b/wallet/src/androidTest/java/ch/admin/bag/covidcertificate/wallet/CertUtil.kt new file mode 100644 index 00000000..cfd432dc --- /dev/null +++ b/wallet/src/androidTest/java/ch/admin/bag/covidcertificate/wallet/CertUtil.kt @@ -0,0 +1,214 @@ +package ch.admin.bag.covidcertificate.wallet + +import android.app.Activity.RESULT_OK +import android.app.Instrumentation +import android.content.Intent +import androidx.test.platform.app.InstrumentationRegistry +import com.fasterxml.jackson.databind.ObjectMapper +import android.net.Uri +import android.provider.Settings.Global.getString +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onIdle +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.NoMatchingViewException +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.ViewPagerActions +import androidx.test.espresso.intent.Intents.intending +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.* +import ch.admin.bag.covidcertificate.sdk.core.models.state.VerificationState +import com.squareup.moshi.Moshi +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import org.hamcrest.Matchers.* +import java.io.File +import java.net.URL +import java.security.KeyStore +import java.security.PrivateKey +import java.security.Signature +import java.text.SimpleDateFormat +import java.util.* +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + + +private val keystore = BuildConfig.KEYSTORE +private val keystorePass = BuildConfig.KEYSTORE_PASS +private val otp = BuildConfig.OTP +private val url = URL("https://ws.covidcertificate-a.bag.admin.ch/api/v1/covidcertificate/vaccination") +private val httpClient = buildClient() +private val vaccineDateFormat = SimpleDateFormat("yyyy-MM-dd") +private val moshi = Moshi.Builder().build() +private val mapper = ObjectMapper() + +enum class UiValidityState{ + VALID, + EXPIRED +} + +fun checkCertValidity(expected: UiValidityState){ + onView(allOf( + withId(R.id.homescreen_certificates_view_pager), + isDisplayed() + )).perform(ViewActions.click()) + /*onView(allOf( + withId(R.id.certificate_page_card) + )).perform(ViewActions.click())*/ + when(expected){ + UiValidityState.VALID -> onView(allOf(withId(R.id.certificate_detail_info))).check(matches(withText("Only valid in combination with \nan identity document"))) + UiValidityState.EXPIRED -> { + onView(allOf(withId(R.id.certificate_detail_info))) + .check(matches(anyOf( + withText(containsString("expired")), + withText(containsString("not valid")) + ) + )) + } + } + + Espresso.pressBack() + +} + +fun importCert(uri: Uri){ + intending(hasAction(Intent.ACTION_GET_CONTENT)) + .respondWith(Instrumentation.ActivityResult(RESULT_OK, Intent().setData(uri))) + try { + onView( + allOf( + withId(R.id.homescreen_scan_button_small), + isDisplayed() + ) + ).perform(ViewActions.click()) + + }catch(e: NoMatchingViewException){ + //looks like this is the first cert being imported + } + + onView( + allOf( + withId(R.id.option_import_pdf), + isDisplayed() + ) + ).perform(ViewActions.click()) + + Thread.sleep(500) + onIdle() + val addButton = onView( + allOf( + withId(R.id.certificate_add_button), + isDisplayed() + ) + ) + addButton.perform(ViewActions.click()) +} + +/** + * Downloads a vaccine cert from the API + * @return the path of the downloaded PDF + */ +fun downloadVaccineCert( + vaccineProduct: String, + dosesGiven: Int, + dosesRequired: Int, + date: Date, + name: String = "Chuck Tester", + dob: String = "1999-09-09", + country: String = "CH" +): Uri { + val dateString = vaccineDateFormat.format(date) + val requestPayload = CertGenerationPayload( + CertGenerationPayload.Companion.Name(name.split(' ')[1], name.split(' ')[0]), + dob, + listOf( + CertGenerationPayload.Companion.VaccinationInfo( + vaccineProduct, + dosesGiven, + dosesRequired, + dateString, + country + ) + ), + "de", + otp + ) + //val requestData = mapper.writeValueAsString(requestPayload) + val requestData = mapper.writeValueAsString(requestPayload) + + val request = Request.Builder() + .addHeader("X-Signature", sign(requestData)) + .addHeader("Content-Type", "application/json") + .method("POST", RequestBody.create("application/json".toMediaTypeOrNull(), requestData)) + .url(url) + .build() + //Download and check result + val response = httpClient.newCall(request).execute() + assert(response.isSuccessful) + val result = mapper.readTree(response.body!!.string()) + val pdf = Base64.getDecoder().decode(result.get("pdf").asText()) + //Write result to file + val context = InstrumentationRegistry.getInstrumentation().targetContext + val file = File(context.filesDir, "${result.get("uvci").asText()}.pdf") + file.writeBytes(pdf) + return Uri.fromFile(file) +} + +/** + * Sign a cert download request (json) + */ +private fun sign(payload: String): String{ + val signature = Signature.getInstance("SHA256WithRSA") + val ks = KeyStore.getInstance("pkcs12") + ks.load(Base64.getDecoder().decode(keystore).inputStream(), keystorePass.toCharArray()) + + signature.initSign(ks.getKey("covid", keystorePass.toCharArray()) as PrivateKey) + val normalizedPayload = payload.replace(" ", "") + signature.update(normalizedPayload.toByteArray()) + val result = signature.sign() + return Base64.getEncoder().encodeToString(result) +} + + +private fun buildClient(): OkHttpClient { + val ks = KeyStore.getInstance("pkcs12") + ks.load(Base64.getDecoder().decode(keystore).inputStream(), keystorePass.toCharArray()) + val trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(null as KeyStore?) + val trustManagers = trustManagerFactory.trustManagers + val keyManagerFactory = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + keyManagerFactory.init(ks, keystorePass.toCharArray()) + val keyManagers = keyManagerFactory.keyManagers + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(keyManagers, trustManagers, null) + val sslSocketFactory = sslContext.socketFactory + return OkHttpClient.Builder() + .sslSocketFactory(sslSocketFactory, trustManagers[0] as X509TrustManager) + .build() +} + +private data class CertGenerationPayload( + val name: Name, + val dateOfBirth: String, + val vaccinationInfo: List, + val language: String, + val otp: String){ + companion object { + data class Name(val familyName: String, val givenName: String) + data class VaccinationInfo( + val medicinalProductCode: String, + val numberOfDoses: Int, + val totalNumberOfDoses: Int, + val vaccinationDate: String, + val countryOfVaccination: String) + } +} + + diff --git a/wallet/src/androidTest/java/ch/admin/bag/covidcertificate/wallet/EspressoUtil.kt b/wallet/src/androidTest/java/ch/admin/bag/covidcertificate/wallet/EspressoUtil.kt index f1ea171f..e12e666c 100644 --- a/wallet/src/androidTest/java/ch/admin/bag/covidcertificate/wallet/EspressoUtil.kt +++ b/wallet/src/androidTest/java/ch/admin/bag/covidcertificate/wallet/EspressoUtil.kt @@ -50,6 +50,18 @@ open class EspressoUtil { ) ) materialButton4.perform(ViewActions.click()) + Espresso.onIdle() + try{ + val materialButton5 = Espresso.onView( + Matchers.allOf( + ViewMatchers.withId(R.id.info_dialog_close_button), + ViewMatchers.isDisplayed() + ) + ) + materialButton5.perform(ViewActions.click()) + }catch(e: Exception){ + + } } // The standard scrollTo Action does not support NestedScrollView. This implementation does support NestedScrollView in