Skip to content

Commit

Permalink
[Attestation] Adds AttestationError to `IntegrityStandardRequestMan…
Browse files Browse the repository at this point in the history
…ager` (#9832)

# Summary
Added error handling and mapping for attestation-related errors, introducing a new `AttestationError` class that provides detailed error types and retriability information.

# Motivation
- AttestationError types are deterministic, allowing to easily log analytics
- Adds retriability info (based on https://developer.android.com/google/play/integrity/error-codes). A follow-up PR will include retriability logic based on this.

# Testing
- [x] Added tests
  - Added test cases for success and failure scenarios in `IntegrityStandardRequestManagerTest`
  - Verified correct error type mapping for Play Services errors
  - Added test coverage for error retriability conditions
  • Loading branch information
carlosmuvi-stripe authored Jan 2, 2025
1 parent ce66d81 commit 25717df
Show file tree
Hide file tree
Showing 4 changed files with 103 additions and 10 deletions.
1 change: 1 addition & 0 deletions stripe-attestation/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies {
testImplementation testLibs.kotlin.annotations
testImplementation testLibs.kotlin.coroutines
testImplementation testLibs.kotlin.junit
testImplementation testLibs.mockito.kotlin
}

android {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.stripe.attestation

import androidx.annotation.RestrictTo
import com.google.android.play.core.integrity.StandardIntegrityException
import com.google.android.play.core.integrity.model.StandardIntegrityErrorCode

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
class AttestationError(
val errorType: ErrorType,
message: String,
cause: Throwable? = null
) : Exception(message, cause) {

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
enum class ErrorType(
val isRetriable: Boolean
) {
API_NOT_AVAILABLE(isRetriable = false),
APP_NOT_INSTALLED(isRetriable = false),
APP_UID_MISMATCH(isRetriable = false),
CANNOT_BIND_TO_SERVICE(isRetriable = true),
CLIENT_TRANSIENT_ERROR(isRetriable = true),
CLOUD_PROJECT_NUMBER_IS_INVALID(isRetriable = false),
GOOGLE_SERVER_UNAVAILABLE(isRetriable = true),
INTEGRITY_TOKEN_PROVIDER_INVALID(isRetriable = false),
INTERNAL_ERROR(isRetriable = true),
NO_ERROR(isRetriable = false),
NETWORK_ERROR(isRetriable = true),
PLAY_SERVICES_NOT_FOUND(isRetriable = false),
PLAY_SERVICES_VERSION_OUTDATED(isRetriable = false),
PLAY_STORE_NOT_FOUND(isRetriable = true),
PLAY_STORE_VERSION_OUTDATED(isRetriable = false),
REQUEST_HASH_TOO_LONG(isRetriable = false),
TOO_MANY_REQUESTS(isRetriable = true),
MAX_RETRIES_EXCEEDED(isRetriable = false),
UNKNOWN(isRetriable = false)
}

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
companion object {
fun fromException(exception: Throwable): AttestationError = when (exception) {
is StandardIntegrityException -> AttestationError(
errorType = errorCodeToErrorTypeMap[exception.errorCode] ?: ErrorType.UNKNOWN,
message = exception.message ?: "Integrity error occurred",
cause = exception
)
else -> AttestationError(
errorType = ErrorType.UNKNOWN,
message = "An unknown error occurred",
cause = exception
)
}

private val errorCodeToErrorTypeMap = mapOf(
StandardIntegrityErrorCode.API_NOT_AVAILABLE to ErrorType.API_NOT_AVAILABLE,
StandardIntegrityErrorCode.APP_NOT_INSTALLED to ErrorType.APP_NOT_INSTALLED,
StandardIntegrityErrorCode.APP_UID_MISMATCH to ErrorType.APP_UID_MISMATCH,
StandardIntegrityErrorCode.CANNOT_BIND_TO_SERVICE to ErrorType.CANNOT_BIND_TO_SERVICE,
StandardIntegrityErrorCode.CLIENT_TRANSIENT_ERROR to ErrorType.CLIENT_TRANSIENT_ERROR,
StandardIntegrityErrorCode.CLOUD_PROJECT_NUMBER_IS_INVALID to ErrorType.CLOUD_PROJECT_NUMBER_IS_INVALID,
StandardIntegrityErrorCode.GOOGLE_SERVER_UNAVAILABLE to ErrorType.GOOGLE_SERVER_UNAVAILABLE,
StandardIntegrityErrorCode.INTEGRITY_TOKEN_PROVIDER_INVALID to ErrorType.INTEGRITY_TOKEN_PROVIDER_INVALID,
StandardIntegrityErrorCode.INTERNAL_ERROR to ErrorType.INTERNAL_ERROR,
StandardIntegrityErrorCode.NETWORK_ERROR to ErrorType.NETWORK_ERROR,
StandardIntegrityErrorCode.NO_ERROR to ErrorType.NO_ERROR,
StandardIntegrityErrorCode.PLAY_SERVICES_NOT_FOUND to ErrorType.PLAY_SERVICES_NOT_FOUND,
StandardIntegrityErrorCode.PLAY_SERVICES_VERSION_OUTDATED to ErrorType.PLAY_SERVICES_VERSION_OUTDATED,
StandardIntegrityErrorCode.PLAY_STORE_NOT_FOUND to ErrorType.PLAY_STORE_NOT_FOUND,
StandardIntegrityErrorCode.PLAY_STORE_VERSION_OUTDATED to ErrorType.PLAY_STORE_VERSION_OUTDATED,
StandardIntegrityErrorCode.REQUEST_HASH_TOO_LONG to ErrorType.REQUEST_HASH_TOO_LONG,
StandardIntegrityErrorCode.TOO_MANY_REQUESTS to ErrorType.TOO_MANY_REQUESTS
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,13 @@ class IntegrityStandardRequestManager(

finishedTask.toResult()
.onSuccess { integrityTokenProvider = it }
.onFailure { error -> logError("Integrity: Failed to prepare integrity token", error) }
.getOrThrow()
}
.map {}
.recoverCatching {
logError("Integrity - Failed to prepare integrity token", it)
throw AttestationError.fromException(it)
}

override suspend fun requestToken(
requestIdentifier: String?,
Expand All @@ -69,9 +73,10 @@ class IntegrityStandardRequestManager(
.build()
).awaitTask()

finishedTask.toResult()
.mapCatching { it.token() }
.onFailure { error -> logError("Integrity - Failed to request integrity token", error) }
.getOrThrow()
}
finishedTask.toResult().getOrThrow()
}.map { it.token() }
.recoverCatching {
logError("Integrity - Failed to request integrity token", it)
throw AttestationError.fromException(it)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,23 @@ package com.stripe.attestation
import android.app.Activity
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.Tasks
import com.google.android.play.core.integrity.StandardIntegrityException
import com.google.android.play.core.integrity.StandardIntegrityManager
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityToken
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider
import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest
import com.google.android.play.core.integrity.model.StandardIntegrityErrorCode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import kotlin.test.Test
import kotlin.test.assertEquals

class IntegrityStandardRequestManagerTest {

Expand All @@ -26,7 +31,7 @@ class IntegrityStandardRequestManagerTest {
}

@Test
fun `prepare - success`() = runTest {
fun `prepare - success returns successful result`() = runTest {
val tokenProvider = FakeStandardIntegrityTokenProvider(Tasks.forResult(FakeStandardIntegrityToken()))
val integrityStandardRequestManager = buildRequestManager(
prepareTask = Tasks.forResult(tokenProvider),
Expand All @@ -38,14 +43,15 @@ class IntegrityStandardRequestManagerTest {
}

@Test
fun `prepare - failure`() = runTest {
fun `prepare - failure on prepare task returns Attestation error`() = runTest {
val integrityStandardRequestManager = buildRequestManager(
prepareTask = Tasks.forException(Exception("Failed to build token provider")),
)

val result = integrityStandardRequestManager.prepare()

assert(result.isFailure)
assert(result.exceptionOrNull() is AttestationError)
}

@Test
Expand All @@ -62,8 +68,12 @@ class IntegrityStandardRequestManagerTest {
}

@Test
fun `requestToken - failure`() = runTest {
val tokenProvider = FakeStandardIntegrityTokenProvider(Tasks.forException(Exception("Failed to request token")))
fun `requestToken - failure returns Attestation error with correct type`() = runTest {
val exception = mock<StandardIntegrityException> {
on { errorCode } doReturn StandardIntegrityErrorCode.PLAY_SERVICES_NOT_FOUND
}
val tokenProvider = FakeStandardIntegrityTokenProvider(Tasks.forException(exception))

val integrityStandardRequestManager = buildRequestManager(
prepareTask = Tasks.forResult(tokenProvider),
)
Expand All @@ -72,6 +82,9 @@ class IntegrityStandardRequestManagerTest {
val result = integrityStandardRequestManager.requestToken("requestIdentifier")

assert(result.isFailure)
assert(result.exceptionOrNull() is AttestationError)
val error = result.exceptionOrNull() as AttestationError
assertEquals(error.errorType, AttestationError.ErrorType.PLAY_SERVICES_NOT_FOUND)
}

@After
Expand Down

0 comments on commit 25717df

Please sign in to comment.