diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/retention/ContactDiaryWorkScheduler.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/retention/ContactDiaryWorkScheduler.kt index d989e8146ceca7b951e43f8b005ad9c3a20e1108..15bbc81bdc3ca0ba7302bdecb7440d01772035d9 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/retention/ContactDiaryWorkScheduler.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/retention/ContactDiaryWorkScheduler.kt @@ -3,6 +3,7 @@ package de.rki.coronawarnapp.contactdiary.retention import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.WorkManager import dagger.Reusable +import timber.log.Timber import javax.inject.Inject @Reusable @@ -16,6 +17,7 @@ class ContactDiaryWorkScheduler @Inject constructor( * Replace with new if older work exists. */ fun schedulePeriodic() { + Timber.d("ContactDiaryWorkScheduler schedulePeriodic()") // Create unique work and enqueue workManager.enqueueUniquePeriodicWork( PERIODIC_WORK_NAME, diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/Analytics.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/Analytics.kt index c82bb2fd229c62772a1e4765b7c396afddb9992b..dab8333ca7fc18e204efa713891a1fad97f1b70f 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/Analytics.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/Analytics.kt @@ -9,13 +9,16 @@ import de.rki.coronawarnapp.datadonation.analytics.server.DataDonationAnalyticsS import de.rki.coronawarnapp.datadonation.analytics.storage.AnalyticsSettings import de.rki.coronawarnapp.datadonation.analytics.storage.LastAnalyticsSubmissionLogger import de.rki.coronawarnapp.datadonation.safetynet.DeviceAttestation +import de.rki.coronawarnapp.datadonation.safetynet.SafetyNetException import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaData import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaDataRequestAndroid import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.util.TimeStamper +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout import org.joda.time.Hours import org.joda.time.Instant import timber.log.Timber @@ -36,36 +39,36 @@ class Analytics @Inject constructor( ) { private val submissionLockoutMutex = Mutex() - private suspend fun trySubmission(analyticsConfig: AnalyticsConfig, ppaData: PpaData.PPADataAndroid): Boolean { - try { + private suspend fun trySubmission(analyticsConfig: AnalyticsConfig, ppaData: PpaData.PPADataAndroid): Result { + return try { val ppaAttestationRequest = PPADeviceAttestationRequest( ppaData = ppaData ) - Timber.d("Starting safety net device attestation") + Timber.tag(TAG).d("Starting safety net device attestation") val attestation = deviceAttestation.attest(ppaAttestationRequest) attestation.requirePass(analyticsConfig.safetyNetRequirements) - Timber.d("Safety net attestation passed") + Timber.tag(TAG).d("Safety net attestation passed") val ppaContainer = PpaDataRequestAndroid.PPADataRequestAndroid.newBuilder() .setAuthentication(attestation.accessControlProtoBuf) .setPayload(ppaData) .build() - Timber.d("Starting analytics upload") + Timber.tag(TAG).d("Starting analytics upload") dataDonationAnalyticsServer.uploadAnalyticsData(ppaContainer) - Timber.d("Analytics upload finished") + Timber.tag(TAG).d("Analytics upload finished") - return true + Result(successful = true) } catch (err: Exception) { - Timber.e(err, "Error during analytics submission") err.reportProblem(tag = TAG, info = "An error occurred during analytics submission") - return false + val retry = err is SafetyNetException && err.type == SafetyNetException.Type.ATTESTATION_REQUEST_FAILED + Result(successful = false, shouldRetry = retry) } } @@ -74,7 +77,7 @@ class Analytics @Inject constructor( val contributions = donorModules.mapNotNull { val moduleName = it::class.simpleName - Timber.d("Beginning donation for module: %s", moduleName) + Timber.tag(TAG).d("Beginning donation for module: %s", moduleName) try { it.beginDonation(request) } catch (e: Exception) { @@ -85,7 +88,7 @@ class Analytics @Inject constructor( contributions.forEach { val moduleName = it::class.simpleName - Timber.d("Injecting contribution: %s", moduleName) + Timber.tag(TAG).d("Injecting contribution: %s", moduleName) try { it.injectData(ppaDataBuilder) } catch (e: Exception) { @@ -97,8 +100,8 @@ class Analytics @Inject constructor( } @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - suspend fun submitAnalyticsData(analyticsConfig: AnalyticsConfig) { - Timber.d("Starting analytics submission") + suspend fun submitAnalyticsData(analyticsConfig: AnalyticsConfig): Result { + Timber.tag(TAG).d("Starting analytics submission") val ppaDataBuilder = PpaData.PPADataAndroid.newBuilder() @@ -106,19 +109,28 @@ class Analytics @Inject constructor( val analyticsProto = ppaDataBuilder.build() - val success = trySubmission(analyticsConfig, analyticsProto) + val result = try { + // 6min, if attestation and/or submission takes longer than that, + // then we want to give modules still time to cleanup and get into a consistent state. + withTimeout(360_000) { + trySubmission(analyticsConfig, analyticsProto) + } + } catch (e: TimeoutCancellationException) { + Timber.tag(TAG).e(e, "trySubmission() timed out after 360s.") + Result(successful = false, shouldRetry = true) + } contributions.forEach { val moduleName = it::class.simpleName - Timber.d("Finishing contribution: %s", moduleName) + Timber.tag(TAG).d("Finishing contribution($result) for %s", moduleName) try { - it.finishDonation(success) + it.finishDonation(result.successful) } catch (e: Exception) { - e.reportProblem(TAG, "finishDonation($success): $moduleName failed") + e.reportProblem(TAG, "finishDonation($result): $moduleName failed") } } - if (success) { + if (result.successful) { settings.lastSubmittedTimestamp.update { timeStamper.nowUTC } @@ -126,7 +138,8 @@ class Analytics @Inject constructor( logger.storeAnalyticsData(analyticsProto) } - Timber.d("Finished analytics submission success=%s", success) + Timber.tag(TAG).d("Finished analytics submission result=%s", result) + return result } @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @@ -158,36 +171,36 @@ class Analytics @Inject constructor( return onboarding.plus(Hours.hours(ONBOARDING_DELAY_HOURS).toStandardDuration()).isAfter(timeStamper.nowUTC) } - suspend fun submitIfWanted() = submissionLockoutMutex.withLock { - Timber.d("Checking analytics conditions") + suspend fun submitIfWanted(): Result = submissionLockoutMutex.withLock { + Timber.tag(TAG).d("Checking analytics conditions") val analyticsConfig: AnalyticsConfig = appConfigProvider.getAppConfig().analytics if (stopDueToNoAnalyticsConfig(analyticsConfig)) { - Timber.w("Aborting Analytics submission due to noAnalyticsConfig") - return + Timber.tag(TAG).w("Aborting Analytics submission due to noAnalyticsConfig") + return@withLock Result(successful = false) } if (stopDueToNoUserConsent()) { - Timber.w("Aborting Analytics submission due to noUserConsent") - return + Timber.tag(TAG).w("Aborting Analytics submission due to noUserConsent") + return@withLock Result(successful = false) } if (stopDueToProbabilityToSubmit(analyticsConfig)) { - Timber.w("Aborting Analytics submission due to probabilityToSubmit") - return + Timber.tag(TAG).w("Aborting Analytics submission due to probabilityToSubmit") + return@withLock Result(successful = false) } if (stopDueToLastSubmittedTimestamp()) { - Timber.w("Aborting Analytics submission due to lastSubmittedTimestamp") - return + Timber.tag(TAG).w("Aborting Analytics submission due to lastSubmittedTimestamp") + return@withLock Result(successful = false) } if (stopDueToTimeSinceOnboarding()) { - Timber.w("Aborting Analytics submission due to timeSinceOnboarding") - return + Timber.tag(TAG).w("Aborting Analytics submission due to timeSinceOnboarding") + return@withLock Result(successful = false) } - submitAnalyticsData(analyticsConfig) + return@withLock submitAnalyticsData(analyticsConfig) } private suspend fun deleteAllData() = submissionLockoutMutex.withLock { @@ -209,6 +222,11 @@ class Analytics @Inject constructor( fun isAnalyticsEnabledFlow(): Flow<Boolean> = settings.analyticsEnabled.flow + data class Result( + val successful: Boolean, + val shouldRetry: Boolean = false + ) + companion object { private val TAG = Analytics::class.java.simpleName private const val LAST_SUBMISSION_MIN_AGE_HOURS = 23 diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/worker/DataDonationAnalyticsPeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/worker/DataDonationAnalyticsPeriodicWorker.kt index c964f618cc1c50400fa516c0a7a6abc646df42c6..0920f50625cd7cc07b9d2262d701b3e7a82b5716 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/worker/DataDonationAnalyticsPeriodicWorker.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/worker/DataDonationAnalyticsPeriodicWorker.kt @@ -20,8 +20,7 @@ class DataDonationAnalyticsPeriodicWorker @AssistedInject constructor( @Assisted context: Context, @Assisted workerParams: WorkerParameters, private val analytics: Analytics -) : - CoroutineWorker(context, workerParams) { +) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result { Timber.tag(TAG).d("Background job started. Run attempt: $runAttemptCount") @@ -31,15 +30,19 @@ class DataDonationAnalyticsPeriodicWorker @AssistedInject constructor( return Result.failure() } - var result = Result.success() - try { - analytics.submitIfWanted() + + return try { + val analyticsResult = analytics.submitIfWanted() + Timber.tag(TAG).d("submitIfWanted() finished: %s", analyticsResult) + when { + analyticsResult.successful -> Result.success() + analyticsResult.shouldRetry -> Result.retry() + else -> Result.failure() + } } catch (e: Exception) { - Timber.tag(TAG).d(e) - result = Result.retry() + Timber.tag(TAG).w(e, "submitIfWanted() failed unexpectedly") + Result.failure() } - - return result } @AssistedFactory diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/worker/DataDonationAnalyticsScheduler.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/worker/DataDonationAnalyticsScheduler.kt index 3b556a0025730a7471f0d34fa29adcfd9da83c14..d4442e6890d606c91786ee01a9673fff21a141a1 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/worker/DataDonationAnalyticsScheduler.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/worker/DataDonationAnalyticsScheduler.kt @@ -3,6 +3,7 @@ package de.rki.coronawarnapp.datadonation.analytics.worker import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.WorkManager import dagger.Reusable +import timber.log.Timber import javax.inject.Inject /** @@ -23,7 +24,8 @@ class DataDonationAnalyticsScheduler @Inject constructor( */ fun schedulePeriodic() { val initialDelay = timeCalculation.getDelay() - + // TODO Replace with logic that checks if already scheduled workManager. workManager.getWorkInfosByTag() + Timber.d("schedulePeriodic() initialDelay(if not yet scheduled)=%s", initialDelay) // Create unique work and enqueue workManager.enqueueUniquePeriodicWork( PERIODIC_WORK_NAME, diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/safetynet/SafetyNetClientWrapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/safetynet/SafetyNetClientWrapper.kt index 9cc34acaa7d66707d69e48d56535881d1546c632..f1b732d2640e529df3d79d7667492bf8c58081a9 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/safetynet/SafetyNetClientWrapper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/safetynet/SafetyNetClientWrapper.kt @@ -10,6 +10,7 @@ import dagger.Reusable import de.rki.coronawarnapp.datadonation.safetynet.SafetyNetException.Type import de.rki.coronawarnapp.environment.EnvironmentSetup import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeout import okio.ByteString import okio.ByteString.Companion.decodeBase64 @@ -17,7 +18,6 @@ import timber.log.Timber import javax.inject.Inject import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine @Reusable class SafetyNetClientWrapper @Inject constructor( @@ -27,9 +27,9 @@ class SafetyNetClientWrapper @Inject constructor( suspend fun attest(nonce: ByteArray): Report { val response = try { - withTimeout(30 * 1000L) { callClient(nonce) } + withTimeout(180_000) { callClient(nonce) } } catch (e: TimeoutCancellationException) { - throw SafetyNetException(Type.ATTESTATION_FAILED, "Attestation timeout.", e) + throw SafetyNetException(Type.ATTESTATION_REQUEST_FAILED, "Attestation timeout (us).", e) } val jwsResult = response.jwsResult ?: throw SafetyNetException( @@ -73,22 +73,46 @@ class SafetyNetClientWrapper @Inject constructor( return JsonParser.parseString(rawJson).asJsonObject } - private suspend fun callClient(nonce: ByteArray): SafetyNetApi.AttestationResponse = suspendCoroutine { cont -> - safetyNetClient.attest(nonce, environmentSetup.safetyNetApiKey) - .addOnSuccessListener { - Timber.tag(TAG).v("Attestation finished with %s", it) - cont.resume(it) - } - .addOnFailureListener { - Timber.tag(TAG).w(it, "Attestation failed.") - val wrappedError = if (it is ApiException && it.statusCode == CommonStatusCodes.NETWORK_ERROR) { - SafetyNetException(Type.ATTESTATION_REQUEST_FAILED, "Network error", it) - } else { - SafetyNetException(Type.ATTESTATION_FAILED, "SafetyNet client returned an error.", it) + private suspend fun callClient(nonce: ByteArray): SafetyNetApi.AttestationResponse = + suspendCancellableCoroutine { cont -> + safetyNetClient.attest(nonce, environmentSetup.safetyNetApiKey) + .addOnSuccessListener { + Timber.tag(TAG).v("Attestation finished with %s", it) + cont.resume(it) } - cont.resumeWithException(wrappedError) - } - } + .addOnFailureListener { + Timber.tag(TAG).w(it, "Attestation failed.") + val defaultError = + SafetyNetException(Type.ATTESTATION_FAILED, "SafetyNet client returned an error.", it) + + if (it !is ApiException) { + cont.resumeWithException(defaultError) + return@addOnFailureListener + } + + val apiError = when (it.statusCode) { + CommonStatusCodes.TIMEOUT -> SafetyNetException( + Type.ATTESTATION_REQUEST_FAILED, + "Timeout (them)", + it + ) + // com.google.android.gms.common.api.ApiException: 20: + // The connection to Google Play services was lost due to service disconnection. + // Last reason for disconnect: Timing out service connection. + CommonStatusCodes.CONNECTION_SUSPENDED_DURING_CALL, + CommonStatusCodes.RECONNECTION_TIMED_OUT_DURING_UPDATE, + CommonStatusCodes.RECONNECTION_TIMED_OUT, + CommonStatusCodes.NETWORK_ERROR -> SafetyNetException( + Type.ATTESTATION_REQUEST_FAILED, + "Network error (${it.statusCode})", + it + ) + else -> defaultError + } + + cont.resumeWithException(apiError) + } + } data class Report( val jwsResult: String, diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsTest.kt index c8f645bdb07ae80b7059cc2b30d525da0f1ee8d3..306ff85627dfbb7a37afd3a54ae02c870bd3f07c 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsTest.kt @@ -12,11 +12,13 @@ import de.rki.coronawarnapp.datadonation.analytics.server.DataDonationAnalyticsS import de.rki.coronawarnapp.datadonation.analytics.storage.AnalyticsSettings import de.rki.coronawarnapp.datadonation.analytics.storage.LastAnalyticsSubmissionLogger import de.rki.coronawarnapp.datadonation.safetynet.DeviceAttestation +import de.rki.coronawarnapp.datadonation.safetynet.SafetyNetException import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaData import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaDataRequestAndroid import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpacAndroid import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.util.TimeStamper +import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations import io.mockk.Runs import io.mockk.clearAllMocks @@ -28,6 +30,7 @@ import io.mockk.just import io.mockk.mockk import io.mockk.mockkObject import io.mockk.spyk +import kotlinx.coroutines.delay import kotlinx.coroutines.test.runBlockingTest import org.joda.time.Days import org.joda.time.Instant @@ -102,7 +105,11 @@ class AnalyticsTest : BaseTest() { val analytics = createInstance() runBlockingTest2 { - analytics.submitIfWanted() + val result = analytics.submitIfWanted() + result.apply { + successful shouldBe false + shouldRetry shouldBe false + } } coVerify(exactly = 1) { @@ -122,7 +129,11 @@ class AnalyticsTest : BaseTest() { val analytics = createInstance() runBlockingTest2 { - analytics.submitIfWanted() + val result = analytics.submitIfWanted() + result.apply { + successful shouldBe false + shouldRetry shouldBe false + } } coVerify(exactly = 1) { @@ -143,7 +154,11 @@ class AnalyticsTest : BaseTest() { val analytics = createInstance() runBlockingTest2 { - analytics.submitIfWanted() + val result = analytics.submitIfWanted() + result.apply { + successful shouldBe false + shouldRetry shouldBe false + } } coVerify(exactly = 1) { @@ -165,7 +180,11 @@ class AnalyticsTest : BaseTest() { val analytics = createInstance() runBlockingTest2 { - analytics.submitIfWanted() + val result = analytics.submitIfWanted() + result.apply { + successful shouldBe false + shouldRetry shouldBe false + } } coVerify(exactly = 1) { @@ -188,7 +207,11 @@ class AnalyticsTest : BaseTest() { val analytics = createInstance() runBlockingTest2 { - analytics.submitIfWanted() + val result = analytics.submitIfWanted() + result.apply { + successful shouldBe false + shouldRetry shouldBe false + } } coVerify(exactly = 1) { @@ -239,7 +262,11 @@ class AnalyticsTest : BaseTest() { val analytics = createInstance() runBlockingTest2 { - analytics.submitIfWanted() + val result = analytics.submitIfWanted() + result.apply { + successful shouldBe true + shouldRetry shouldBe false + } } coVerify(exactly = 1) { @@ -327,4 +354,105 @@ class AnalyticsTest : BaseTest() { analytics.submitAnalyticsData(any()) } } + + @Test + fun `we catch safetynet timeout and enable retry`() { + val exposureRiskDonation = mockk<ExposureRiskMetadataDonor.ExposureRiskMetadataContribution>().apply { + coEvery { injectData(any()) } just Runs + coEvery { finishDonation(any()) } just Runs + } + coEvery { exposureRiskMetadataDonor.beginDonation(any()) } returns exposureRiskDonation + + coEvery { deviceAttestation.attest(any()) } throws SafetyNetException( + type = SafetyNetException.Type.ATTESTATION_REQUEST_FAILED, + "Timeout???", + cause = Exception() + ) + + val analytics = createInstance() + + runBlockingTest { + val result = analytics.submitIfWanted() + result.successful shouldBe false + result.shouldRetry shouldBe true + } + + coVerify(exactly = 1) { + exposureRiskMetadataDonor.beginDonation(any()) + exposureRiskDonation.injectData(any()) + exposureRiskDonation.finishDonation(false) + } + + coVerify(exactly = 0) { dataDonationAnalyticsServer.uploadAnalyticsData(any()) } + } + + @Test + fun `overall submission can timeout on safetynet and still allow modules to cleanup`() { + val exposureRiskDonation = mockk<ExposureRiskMetadataDonor.ExposureRiskMetadataContribution>().apply { + coEvery { injectData(any()) } just Runs + coEvery { finishDonation(any()) } just Runs + } + coEvery { exposureRiskMetadataDonor.beginDonation(any()) } returns exposureRiskDonation + + coEvery { deviceAttestation.attest(any()) } coAnswers { + // Timeout should be 360s + delay(370_000) + mockk() + } + + val analytics = createInstance() + + runBlockingTest { + val result = analytics.submitIfWanted() + result.successful shouldBe false + result.shouldRetry shouldBe true + } + + coVerify(exactly = 1) { + exposureRiskMetadataDonor.beginDonation(any()) + exposureRiskDonation.injectData(any()) + exposureRiskDonation.finishDonation(false) + } + + coVerify(exactly = 0) { + dataDonationAnalyticsServer.uploadAnalyticsData(any()) + } + } + + @Test + fun `overall submission can timeout on upload and still allow modules to cleanup`() { + val exposureRiskDonation = mockk<ExposureRiskMetadataDonor.ExposureRiskMetadataContribution>().apply { + coEvery { injectData(any()) } just Runs + coEvery { finishDonation(any()) } just Runs + } + coEvery { exposureRiskMetadataDonor.beginDonation(any()) } returns exposureRiskDonation + + coEvery { deviceAttestation.attest(any()) } returns object : DeviceAttestation.Result { + override val accessControlProtoBuf: PpacAndroid.PPACAndroid + get() = PpacAndroid.PPACAndroid.getDefaultInstance() + + override fun requirePass(requirements: SafetyNetRequirements) {} + } + + coEvery { dataDonationAnalyticsServer.uploadAnalyticsData(any()) } coAnswers { + // Timeout should be 360s + delay(370_000) + mockk() + } + + val analytics = createInstance() + + runBlockingTest { + val result = analytics.submitIfWanted() + result.successful shouldBe false + result.shouldRetry shouldBe true + } + + coVerify(exactly = 1) { + exposureRiskMetadataDonor.beginDonation(any()) + exposureRiskDonation.injectData(any()) + exposureRiskDonation.finishDonation(false) + dataDonationAnalyticsServer.uploadAnalyticsData(any()) + } + } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/worker/DataDonationAnalyticsPeriodicWorkerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/worker/DataDonationAnalyticsPeriodicWorkerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..2ea4a82db16288a86dc944bad41944940386f3d6 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/worker/DataDonationAnalyticsPeriodicWorkerTest.kt @@ -0,0 +1,70 @@ +package de.rki.coronawarnapp.datadonation.analytics.worker + +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import de.rki.coronawarnapp.datadonation.analytics.Analytics +import de.rki.coronawarnapp.worker.BackgroundConstants +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class DataDonationAnalyticsPeriodicWorkerTest : BaseTest() { + + @MockK lateinit var context: Context + @MockK lateinit var analytics: Analytics + @RelaxedMockK lateinit var workerParams: WorkerParameters + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createWorker() = DataDonationAnalyticsPeriodicWorker( + context = context, + workerParams = workerParams, + analytics = analytics + ) + + @Test + fun `if result says retry, do retry`() = runBlockingTest { + coEvery { analytics.submitIfWanted() } returns Analytics.Result(successful = false, shouldRetry = true) + createWorker().doWork() shouldBe ListenableWorker.Result.Retry() + + coEvery { analytics.submitIfWanted() } returns Analytics.Result(successful = false, shouldRetry = false) + createWorker().doWork() shouldBe ListenableWorker.Result.Failure() + + coEvery { analytics.submitIfWanted() } returns Analytics.Result(successful = true, shouldRetry = false) + createWorker().doWork() shouldBe ListenableWorker.Result.Success() + } + + @Test + fun `maximum of 2 retry attemtps`() = runBlockingTest { + val worker = createWorker() + worker.runAttemptCount shouldBe 0 + + every { worker.runAttemptCount } returns BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD + 1 + + worker.doWork() shouldBe ListenableWorker.Result.Failure() + } + + @Test + fun `unexpected errors do not cause a retry`() = runBlockingTest { + coEvery { analytics.submitIfWanted() } throws Exception("SURPRISE!!!") + createWorker().doWork() shouldBe ListenableWorker.Result.Failure() + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/safetynet/SafetyNetClientWrapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/safetynet/SafetyNetClientWrapperTest.kt index a294c548f2549b445b0f788488d89f75633e22f5..71e072bfbea318f54b6664853f4cf32f4246345d 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/safetynet/SafetyNetClientWrapperTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/safetynet/SafetyNetClientWrapperTest.kt @@ -24,7 +24,6 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import testhelpers.BaseTest -import testhelpers.coroutines.runBlockingTest2 import testhelpers.gms.MockGMSTask import java.io.IOException @@ -74,7 +73,7 @@ class SafetyNetClientWrapperTest : BaseTest() { } @Test - fun `attestation can time out`() = runBlockingTest2(ignoreActive = true) { + fun `attestation can time out`() = runBlockingTest { every { safetyNetClient.attest(any(), any()) } returns MockGMSTask.timeout() val resultAsync = async { @@ -83,10 +82,8 @@ class SafetyNetClientWrapperTest : BaseTest() { } } - advanceTimeBy(31 * 1000L) - val error = resultAsync.await() - error.type shouldBe SafetyNetException.Type.ATTESTATION_FAILED + error.type shouldBe SafetyNetException.Type.ATTESTATION_REQUEST_FAILED error.cause shouldBe instanceOf(TimeoutCancellationException::class) }