From 387de57d5e3984489634fa39f59ec813747cc95c Mon Sep 17 00:00:00 2001 From: Karthik Gangineni Date: Wed, 25 Jun 2025 09:17:30 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=E2=8F=BA=20Add=20OAuth2=20client=20token?= =?UTF-8?q?=20fetching=20with=20suspend=20PayPalWebCheckoutClient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement FetchClientToken class for OAuth2 client credentials flow - Add headers parameter to APIRequest for Basic auth support - Convert PayPalWebCheckoutClient.start() to suspend function - Integrate token fetching before launching web checkout - Add comprehensive error handling for token fetch failures --- .../paypal/android/corepayments/APIRequest.kt | 7 +- .../corepayments/api/FetchClientToken.kt | 44 ++++ .../api/FetchClientTokenUnitTest.kt | 214 ++++++++++++++++++ .../PayPalWebCheckoutClient.kt | 12 +- .../PayPalWebCheckoutClientUnitTest.kt | 43 +++- 5 files changed, 314 insertions(+), 6 deletions(-) create mode 100644 CorePayments/src/main/java/com/paypal/android/corepayments/api/FetchClientToken.kt create mode 100644 CorePayments/src/test/java/com/paypal/android/corepayments/api/FetchClientTokenUnitTest.kt diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/APIRequest.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/APIRequest.kt index 23b97fee3..00013f76a 100644 --- a/CorePayments/src/main/java/com/paypal/android/corepayments/APIRequest.kt +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/APIRequest.kt @@ -6,4 +6,9 @@ import androidx.annotation.RestrictTo * @suppress */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -data class APIRequest(val path: String, val method: HttpMethod, val body: String? = null) +data class APIRequest( + val path: String, + val method: HttpMethod, + val body: String? = null, + val headers: Map? = null +) diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/api/FetchClientToken.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/api/FetchClientToken.kt new file mode 100644 index 000000000..481b70d5d --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/api/FetchClientToken.kt @@ -0,0 +1,44 @@ +package com.paypal.android.corepayments.api + +import androidx.annotation.RestrictTo +import com.paypal.android.corepayments.APIClientError +import com.paypal.android.corepayments.APIRequest +import com.paypal.android.corepayments.CoreConfig +import com.paypal.android.corepayments.HttpMethod +import com.paypal.android.corepayments.RestClient +import com.paypal.android.corepayments.base64encoded +import org.json.JSONObject + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class FetchClientToken( + private val coreConfig: CoreConfig, + private val restClient: RestClient = RestClient(configuration = coreConfig), +) { + suspend operator fun invoke(): String { + val requestBody = "grant_type=client_credentials&response_type=token" + + val credentials = "${coreConfig.clientId}:" + val headers = mutableMapOf( + "Authorization" to "Basic ${credentials.base64encoded()}", + "Content-Type" to "application/x-www-form-urlencoded" + ) + + val apiRequest = APIRequest( + path = "v1/oauth2/token", + method = HttpMethod.POST, + body = requestBody, + headers = headers + ) + val httpResponse = restClient.send(apiRequest) + if (httpResponse.isSuccessful) { + val jsonObject = JSONObject(httpResponse.body ?: "") + return jsonObject.getString("access_token") + } else { + throw httpResponse.run { buildError(status, body) } + } + } +} + +private fun buildError(code: Int, message: String?) = APIClientError.payPalCheckoutError( + "Error fetching client token: code: $code, message: ${message ?: "No response body"}" +) diff --git a/CorePayments/src/test/java/com/paypal/android/corepayments/api/FetchClientTokenUnitTest.kt b/CorePayments/src/test/java/com/paypal/android/corepayments/api/FetchClientTokenUnitTest.kt new file mode 100644 index 000000000..582c4b91d --- /dev/null +++ b/CorePayments/src/test/java/com/paypal/android/corepayments/api/FetchClientTokenUnitTest.kt @@ -0,0 +1,214 @@ +package com.paypal.android.corepayments.api + +import com.paypal.android.corepayments.APIRequest +import com.paypal.android.corepayments.CoreConfig +import com.paypal.android.corepayments.Environment +import com.paypal.android.corepayments.HttpMethod +import com.paypal.android.corepayments.HttpResponse +import com.paypal.android.corepayments.PayPalSDKError +import com.paypal.android.corepayments.RestClient +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FetchClientTokenUnitTest { + + private lateinit var restClient: RestClient + private lateinit var coreConfig: CoreConfig + private lateinit var sut: FetchClientToken + + @Before + fun beforeEach() { + coreConfig = CoreConfig("test-client-id", Environment.SANDBOX) + restClient = mockk(relaxed = true) + sut = FetchClientToken(coreConfig, restClient) + } + + @Test + fun `invoke() makes correct API request with proper headers and body`() = runTest { + // Given + val successResponse = HttpResponse( + status = 200, + body = """{"access_token": "test-token", "token_type": "Bearer", "expires_in": 3600}""" + ) + coEvery { restClient.send(any()) } returns successResponse + + val requestSlot = slot() + + // When + val result = sut() + + // Then + coVerify { restClient.send(capture(requestSlot)) } + + val capturedRequest = requestSlot.captured + assertEquals("v1/oauth2/token", capturedRequest.path) + assertEquals(HttpMethod.POST, capturedRequest.method) + assertEquals("grant_type=client_credentials&response_type=token", capturedRequest.body) + + // Verify headers + val headers = capturedRequest.headers!! + assertEquals("application/x-www-form-urlencoded", headers["Content-Type"]) + assert(headers["Authorization"]!!.startsWith("Basic ")) + + assertEquals("test-token", result) + } + + @Test + fun `invoke() returns access token from successful response`() = runTest { + // Given + val expectedToken = "test-access-token-12345" + val successResponse = HttpResponse( + status = 200, + body = """{"access_token": "$expectedToken", "token_type": "Bearer", "expires_in": 3600}""" + ) + coEvery { restClient.send(any()) } returns successResponse + + // When + val result = sut() + + // Then + assertEquals(expectedToken, result) + } + + @Test + fun `invoke() throws APIClientError when response is not successful`() = runTest { + // Given + val errorResponse = HttpResponse( + status = 401, + body = """{"error": "invalid_client", "error_description": "Client authentication failed"}""" + ) + coEvery { restClient.send(any()) } returns errorResponse + + // When/Then + val result = runCatching { sut() } + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is PayPalSDKError) + } + + @Test + fun `invoke() throws APIClientError when response body is null`() = runTest { + // Given + val successResponse = HttpResponse( + status = 200, + body = null + ) + coEvery { restClient.send(any()) } returns successResponse + + // When/Then + val result = runCatching { sut() } + assertTrue(result.isFailure) + // Expected JSONException when parsing null body + } + + @Test + fun `invoke() throws JSONException when response is not valid JSON`() = runTest { + // Given + val successResponse = HttpResponse( + status = 200, + body = "invalid-json-response" + ) + coEvery { restClient.send(any()) } returns successResponse + + // When/Then + val result = runCatching { sut() } + assertTrue(result.isFailure) + // Expected JSONException for invalid JSON + } + + @Test + fun `invoke() throws JSONException when access_token is missing from response`() = runTest { + // Given + val successResponse = HttpResponse( + status = 200, + body = """{"token_type": "Bearer", "expires_in": 3600}""" + ) + coEvery { restClient.send(any()) } returns successResponse + + // When/Then + val result = runCatching { sut() } + assertTrue(result.isFailure) + // Expected JSONException for missing access_token + } + + @Test + fun `invoke() handles empty response body with proper error message`() = runTest { + // Given + val errorResponse = HttpResponse( + status = 500, + body = "" + ) + coEvery { restClient.send(any()) } returns errorResponse + + // When/Then + try { + sut() + assert(false) { "Expected PayPalSDKError to be thrown" } + } catch (e: PayPalSDKError) { + val errorMessage = e.errorDescription + // Just verify it contains some error information + assert(errorMessage.contains("Error fetching client token")) + } + } + + @Test + fun `invoke() includes error message in exception when available`() = runTest { + // Given + val errorMessage = "Server temporarily unavailable" + val errorResponse = HttpResponse( + status = 503, + body = errorMessage + ) + coEvery { restClient.send(any()) } returns errorResponse + + // When/Then + try { + sut() + assert(false) { "Expected PayPalSDKError to be thrown" } + } catch (e: PayPalSDKError) { + val description = e.errorDescription + // Just verify it contains some error information + assert(description.contains("Error fetching client token")) + } + } + + @Test + fun `invoke() creates proper Basic Auth header from client ID`() = runTest { + // Given + val clientId = "test-client-123" + val configWithCustomClientId = CoreConfig(clientId, Environment.LIVE) + val sutWithCustomConfig = FetchClientToken(configWithCustomClientId, restClient) + + val successResponse = HttpResponse( + status = 200, + body = """{"access_token": "token", "token_type": "Bearer"}""" + ) + coEvery { restClient.send(any()) } returns successResponse + + val requestSlot = slot() + + // When + sutWithCustomConfig() + + // Then + coVerify { restClient.send(capture(requestSlot)) } + + val authHeader = requestSlot.captured.headers!!["Authorization"]!! + assert(authHeader.startsWith("Basic ")) + + // Decode and verify the Basic auth contains the client ID + val encodedCredentials = authHeader.substring("Basic ".length) + val decodedCredentials = + String(android.util.Base64.decode(encodedCredentials, android.util.Base64.DEFAULT)) + assertEquals("$clientId:", decodedCredentials) + } +} diff --git a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt index adaa99798..5dbbd7669 100644 --- a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt +++ b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt @@ -4,7 +4,9 @@ import android.content.Context import android.content.Intent import androidx.activity.ComponentActivity import com.paypal.android.corepayments.CoreConfig +import com.paypal.android.corepayments.RestClient import com.paypal.android.corepayments.analytics.AnalyticsService +import com.paypal.android.corepayments.api.FetchClientToken import com.paypal.android.paypalwebpayments.analytics.CheckoutEvent import com.paypal.android.paypalwebpayments.analytics.PayPalWebAnalytics import com.paypal.android.paypalwebpayments.analytics.VaultEvent @@ -17,7 +19,8 @@ import com.paypal.android.paypalwebpayments.analytics.VaultEvent */ class PayPalWebCheckoutClient internal constructor( private val analytics: PayPalWebAnalytics, - private val payPalWebLauncher: PayPalWebLauncher + private val payPalWebLauncher: PayPalWebLauncher, + private val fetchClientToken: FetchClientToken ) { // for analytics tracking @@ -34,6 +37,7 @@ class PayPalWebCheckoutClient internal constructor( constructor(context: Context, configuration: CoreConfig, urlScheme: String) : this( PayPalWebAnalytics(AnalyticsService(context.applicationContext, configuration)), PayPalWebLauncher(urlScheme, configuration), + FetchClientToken(configuration, RestClient(configuration)) ) /** @@ -41,13 +45,17 @@ class PayPalWebCheckoutClient internal constructor( * * @param request [PayPalWebCheckoutRequest] for requesting an order approval */ - fun start( + suspend fun start( activity: ComponentActivity, request: PayPalWebCheckoutRequest ): PayPalPresentAuthChallengeResult { checkoutOrderId = request.orderId analytics.notify(CheckoutEvent.STARTED, checkoutOrderId) + // Fetch client token for authentication + fetchClientToken() + // todo: token will be used while fetching app switch eligibility + val result = payPalWebLauncher.launchPayPalWebCheckout(activity, request) when (result) { is PayPalPresentAuthChallengeResult.Success -> analytics.notify( diff --git a/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt b/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt index 6157f79a9..9a4ef82c5 100644 --- a/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt +++ b/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt @@ -2,14 +2,19 @@ package com.paypal.android.paypalwebpayments import android.content.Intent import androidx.fragment.app.FragmentActivity +import com.paypal.android.corepayments.APIClientError import com.paypal.android.corepayments.PayPalSDKError +import com.paypal.android.corepayments.api.FetchClientToken import com.paypal.android.paypalwebpayments.analytics.PayPalWebAnalytics +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify import junit.framework.TestCase.assertSame import junit.framework.TestCase.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -21,6 +26,7 @@ class PayPalWebCheckoutClientUnitTest { private val activity: FragmentActivity = mockk(relaxed = true) private val analytics = mockk(relaxed = true) + private lateinit var fetchClientToken: FetchClientToken private val intent = Intent() @@ -30,21 +36,27 @@ class PayPalWebCheckoutClientUnitTest { @Before fun beforeEach() { payPalWebLauncher = mockk(relaxed = true) - sut = PayPalWebCheckoutClient(analytics, payPalWebLauncher) + fetchClientToken = mockk(relaxed = true) + sut = PayPalWebCheckoutClient(analytics, payPalWebLauncher, fetchClientToken) + + // Mock successful token fetch by default + coEvery { fetchClientToken() } returns "fake-access-token" } @Test - fun `start() launches PayPal web checkout`() { + fun `start() fetches client token and launches PayPal web checkout`() = runTest { val launchResult = PayPalPresentAuthChallengeResult.Success("auth state") every { payPalWebLauncher.launchPayPalWebCheckout(any(), any()) } returns launchResult val request = PayPalWebCheckoutRequest("fake-order-id") sut.start(activity, request) + + coVerify(exactly = 1) { fetchClientToken() } verify(exactly = 1) { payPalWebLauncher.launchPayPalWebCheckout(activity, request) } } @Test - fun `start() notifies merchant of browser switch failure`() { + fun `start() notifies merchant of browser switch failure`() = runTest { val sdkError = PayPalSDKError(123, "fake error description") val launchResult = PayPalPresentAuthChallengeResult.Failure(sdkError) every { payPalWebLauncher.launchPayPalWebCheckout(any(), any()) } returns launchResult @@ -54,6 +66,31 @@ class PayPalWebCheckoutClientUnitTest { assertSame(launchResult, result) } + @Test + fun `start() propagates FetchClientToken failure`() = runTest { + val tokenError = APIClientError.payPalCheckoutError("Token fetch failed") + coEvery { fetchClientToken() } throws tokenError + + val request = PayPalWebCheckoutRequest("fake-order-id") + + val result = runCatching { sut.start(activity, request) } + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is PayPalSDKError) + } + + @Test + fun `start() does not call launcher when token fetch fails`() = runTest { + val tokenError = APIClientError.payPalCheckoutError("Token fetch failed") + coEvery { fetchClientToken() } throws tokenError + + val request = PayPalWebCheckoutRequest("fake-order-id") + + val result = runCatching { sut.start(activity, request) } + assertTrue(result.isFailure) + + verify(exactly = 0) { payPalWebLauncher.launchPayPalWebCheckout(any(), any()) } + } + @Test fun `vault() launches PayPal web checkout`() { val launchResult = PayPalPresentAuthChallengeResult.Success("auth state") From cf9bececd4d85fe504e20ff8547451afc8c5c589 Mon Sep 17 00:00:00 2001 From: Karthik Gangineni Date: Wed, 25 Jun 2025 16:26:37 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=E2=8F=BA=20Add=20callback-based=20start=20?= =?UTF-8?q?function=20with=20PayPalWebCheckoutCallback=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create PayPalWebCheckoutCallback fun interface following CardApproveOrderCallback pattern - Add overloaded start() function accepting PayPalWebCheckoutCallback --- .../android/corepayments/APIClientError.kt | 31 +---- .../corepayments/PayPalSDKErrorCode.kt | 3 +- .../AuthenticationSecureTokenServiceAPI.kt | 65 +++++++++ .../api/CreateLowScopedAccessTokenResult.kt | 21 +++ .../corepayments/api/FetchClientToken.kt | 44 ------ ...nticationSecureTokenServiceAPIUnitTest.kt} | 128 ++++++++++-------- .../PayPalWebCheckoutCallback.kt | 13 ++ .../PayPalWebCheckoutClient.kt | 66 +++++++-- .../PayPalWebCheckoutClientUnitTest.kt | 41 +++--- 9 files changed, 250 insertions(+), 162 deletions(-) create mode 100644 CorePayments/src/main/java/com/paypal/android/corepayments/api/AuthenticationSecureTokenServiceAPI.kt create mode 100644 CorePayments/src/main/java/com/paypal/android/corepayments/api/CreateLowScopedAccessTokenResult.kt delete mode 100644 CorePayments/src/main/java/com/paypal/android/corepayments/api/FetchClientToken.kt rename CorePayments/src/test/java/com/paypal/android/corepayments/api/{FetchClientTokenUnitTest.kt => AuthenticationSecureTokenServiceAPIUnitTest.kt} (54%) create mode 100644 PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutCallback.kt diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/APIClientError.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/APIClientError.kt index 6ff451dd0..f3f8fa672 100644 --- a/CorePayments/src/main/java/com/paypal/android/corepayments/APIClientError.kt +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/APIClientError.kt @@ -9,9 +9,10 @@ import androidx.annotation.RestrictTo object APIClientError { // 0. An unknown error occurred. - fun unknownError(correlationId: String?) = PayPalSDKError( + fun unknownError(correlationId: String? = null, throwable: Throwable? = null) = PayPalSDKError( code = PayPalSDKErrorCode.UNKNOWN.ordinal, errorDescription = "An unknown error occurred. Contact developer.paypal.com/support.", + reason = throwable, correlationId = correlationId ) @@ -37,12 +38,6 @@ object APIClientError { correlationId = correlationId ) - // 4. There was an error constructing the URLRequest. - val invalidUrlRequest = PayPalSDKError( - code = PayPalSDKErrorCode.INVALID_URL_REQUEST.ordinal, - errorDescription = "An error occurred constructing an HTTP request. Contact developer.paypal.com/support." - ) - // 5. The server's response body returned an error message. fun serverResponseError(correlationId: String?) = PayPalSDKError( code = PayPalSDKErrorCode.SERVER_RESPONSE_ERROR.ordinal, @@ -58,28 +53,6 @@ object APIClientError { correlationId = correlationId ) - val payPalCheckoutError: (description: String) -> PayPalSDKError = { description -> - PayPalSDKError( - code = PayPalSDKErrorCode.CHECKOUT_ERROR.ordinal, - errorDescription = description - ) - } - - val payPalNativeCheckoutError: (description: String, reason: Exception) -> PayPalSDKError = - { description, reason -> - PayPalSDKError( - code = PayPalSDKErrorCode.NATIVE_CHECKOUT_ERROR.ordinal, - errorDescription = description, - reason = reason - ) - } - - fun clientIDNotFoundError(code: Int, correlationId: String?) = PayPalSDKError( - code = code, - errorDescription = "Error fetching clientId. Contact developer.paypal.com/support.", - correlationId = correlationId - ) - fun graphQLJSONParseError(correlationId: String?, reason: Exception): PayPalSDKError { val message = "An error occurred while parsing the GraphQL response JSON. Contact developer.paypal.com/support." diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/PayPalSDKErrorCode.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/PayPalSDKErrorCode.kt index ea5717312..23fdb3106 100644 --- a/CorePayments/src/main/java/com/paypal/android/corepayments/PayPalSDKErrorCode.kt +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/PayPalSDKErrorCode.kt @@ -15,5 +15,6 @@ enum class PayPalSDKErrorCode { SERVER_RESPONSE_ERROR, CHECKOUT_ERROR, NATIVE_CHECKOUT_ERROR, - GRAPHQL_JSON_INVALID_ERROR + GRAPHQL_JSON_INVALID_ERROR, + NO_ACCESS_TOKEN_ERROR, } diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/api/AuthenticationSecureTokenServiceAPI.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/api/AuthenticationSecureTokenServiceAPI.kt new file mode 100644 index 000000000..8a4fa8200 --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/api/AuthenticationSecureTokenServiceAPI.kt @@ -0,0 +1,65 @@ +package com.paypal.android.corepayments.api + +import androidx.annotation.RestrictTo +import com.paypal.android.corepayments.APIClientError +import com.paypal.android.corepayments.APIRequest +import com.paypal.android.corepayments.CoreConfig +import com.paypal.android.corepayments.HttpMethod +import com.paypal.android.corepayments.PayPalSDKError +import com.paypal.android.corepayments.PayPalSDKErrorCode +import com.paypal.android.corepayments.RestClient +import com.paypal.android.corepayments.base64encoded +import org.json.JSONObject + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class AuthenticationSecureTokenServiceAPI( + private val coreConfig: CoreConfig, + private val restClient: RestClient = RestClient(configuration = coreConfig), +) { + suspend fun createLowScopedAccessToken(): CreateLowScopedAccessTokenResult { + val requestBody = "grant_type=client_credentials&response_type=token" + + val headers = mutableMapOf( + "Authorization" to "Basic ${coreConfig.clientId.base64encoded()}:", + "Content-Type" to "application/x-www-form-urlencoded" + ) + + val apiRequest = APIRequest( + path = "v1/oauth2/token", + method = HttpMethod.POST, + body = requestBody, + headers = headers + ) + + return runCatching { + val httpResponse = restClient.send(apiRequest) + val correlationId = httpResponse.headers["paypal-debug-id"] + if (httpResponse.isSuccessful && httpResponse.body != null) { + val jsonObject = JSONObject(httpResponse.body) + val token = jsonObject.optString("access_token") + if (token.isNotEmpty()) { + CreateLowScopedAccessTokenResult.Success(token) + } else { + val error = PayPalSDKError( + code = PayPalSDKErrorCode.NO_ACCESS_TOKEN_ERROR.ordinal, + errorDescription = "Missing access_token in response", + correlationId = correlationId + ) + CreateLowScopedAccessTokenResult.Failure(error) + } + } else { + val error = httpResponse.run { + PayPalSDKError( + code = status, + errorDescription = body ?: "Unknown error", + correlationId = correlationId + ) + } + CreateLowScopedAccessTokenResult.Failure(error) + } + }.getOrElse { throwable -> + val error = APIClientError.unknownError(throwable = throwable) + CreateLowScopedAccessTokenResult.Failure(error) + } + } +} diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/api/CreateLowScopedAccessTokenResult.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/api/CreateLowScopedAccessTokenResult.kt new file mode 100644 index 000000000..6edc99438 --- /dev/null +++ b/CorePayments/src/main/java/com/paypal/android/corepayments/api/CreateLowScopedAccessTokenResult.kt @@ -0,0 +1,21 @@ +package com.paypal.android.corepayments.api + +import androidx.annotation.RestrictTo +import com.paypal.android.corepayments.PayPalSDKError + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +sealed class CreateLowScopedAccessTokenResult { + /** + * The request to get client token was successful. + * + * @property token the access token from the response + */ + data class Success(val token: String) : CreateLowScopedAccessTokenResult() + + /** + * There was an error with the request to get client token. + * + * @property error the error that occurred + */ + data class Failure(val error: PayPalSDKError) : CreateLowScopedAccessTokenResult() +} diff --git a/CorePayments/src/main/java/com/paypal/android/corepayments/api/FetchClientToken.kt b/CorePayments/src/main/java/com/paypal/android/corepayments/api/FetchClientToken.kt deleted file mode 100644 index 481b70d5d..000000000 --- a/CorePayments/src/main/java/com/paypal/android/corepayments/api/FetchClientToken.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.paypal.android.corepayments.api - -import androidx.annotation.RestrictTo -import com.paypal.android.corepayments.APIClientError -import com.paypal.android.corepayments.APIRequest -import com.paypal.android.corepayments.CoreConfig -import com.paypal.android.corepayments.HttpMethod -import com.paypal.android.corepayments.RestClient -import com.paypal.android.corepayments.base64encoded -import org.json.JSONObject - -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -class FetchClientToken( - private val coreConfig: CoreConfig, - private val restClient: RestClient = RestClient(configuration = coreConfig), -) { - suspend operator fun invoke(): String { - val requestBody = "grant_type=client_credentials&response_type=token" - - val credentials = "${coreConfig.clientId}:" - val headers = mutableMapOf( - "Authorization" to "Basic ${credentials.base64encoded()}", - "Content-Type" to "application/x-www-form-urlencoded" - ) - - val apiRequest = APIRequest( - path = "v1/oauth2/token", - method = HttpMethod.POST, - body = requestBody, - headers = headers - ) - val httpResponse = restClient.send(apiRequest) - if (httpResponse.isSuccessful) { - val jsonObject = JSONObject(httpResponse.body ?: "") - return jsonObject.getString("access_token") - } else { - throw httpResponse.run { buildError(status, body) } - } - } -} - -private fun buildError(code: Int, message: String?) = APIClientError.payPalCheckoutError( - "Error fetching client token: code: $code, message: ${message ?: "No response body"}" -) diff --git a/CorePayments/src/test/java/com/paypal/android/corepayments/api/FetchClientTokenUnitTest.kt b/CorePayments/src/test/java/com/paypal/android/corepayments/api/AuthenticationSecureTokenServiceAPIUnitTest.kt similarity index 54% rename from CorePayments/src/test/java/com/paypal/android/corepayments/api/FetchClientTokenUnitTest.kt rename to CorePayments/src/test/java/com/paypal/android/corepayments/api/AuthenticationSecureTokenServiceAPIUnitTest.kt index 582c4b91d..43c07db6b 100644 --- a/CorePayments/src/test/java/com/paypal/android/corepayments/api/FetchClientTokenUnitTest.kt +++ b/CorePayments/src/test/java/com/paypal/android/corepayments/api/AuthenticationSecureTokenServiceAPIUnitTest.kt @@ -5,7 +5,6 @@ import com.paypal.android.corepayments.CoreConfig import com.paypal.android.corepayments.Environment import com.paypal.android.corepayments.HttpMethod import com.paypal.android.corepayments.HttpResponse -import com.paypal.android.corepayments.PayPalSDKError import com.paypal.android.corepayments.RestClient import io.mockk.coEvery import io.mockk.coVerify @@ -13,28 +12,31 @@ import io.mockk.mockk import io.mockk.slot import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +@ExperimentalCoroutinesApi @RunWith(RobolectricTestRunner::class) -class FetchClientTokenUnitTest { +class AuthenticationSecureTokenServiceAPIUnitTest { private lateinit var restClient: RestClient private lateinit var coreConfig: CoreConfig - private lateinit var sut: FetchClientToken + private lateinit var sut: AuthenticationSecureTokenServiceAPI @Before fun beforeEach() { coreConfig = CoreConfig("test-client-id", Environment.SANDBOX) restClient = mockk(relaxed = true) - sut = FetchClientToken(coreConfig, restClient) + sut = AuthenticationSecureTokenServiceAPI(coreConfig, restClient) } @Test - fun `invoke() makes correct API request with proper headers and body`() = runTest { + fun `createLowScopedAccessToken() makes correct API request with proper headers and body`() = + runTest { // Given val successResponse = HttpResponse( status = 200, @@ -45,7 +47,7 @@ class FetchClientTokenUnitTest { val requestSlot = slot() // When - val result = sut() + val result = sut.createLowScopedAccessToken() // Then coVerify { restClient.send(capture(requestSlot)) } @@ -60,11 +62,12 @@ class FetchClientTokenUnitTest { assertEquals("application/x-www-form-urlencoded", headers["Content-Type"]) assert(headers["Authorization"]!!.startsWith("Basic ")) - assertEquals("test-token", result) + assertTrue(result is CreateLowScopedAccessTokenResult.Success) + assertEquals("test-token", (result as CreateLowScopedAccessTokenResult.Success).token) } @Test - fun `invoke() returns access token from successful response`() = runTest { + fun `createLowScopedAccessToken() returns access token from successful response`() = runTest { // Given val expectedToken = "test-access-token-12345" val successResponse = HttpResponse( @@ -74,14 +77,15 @@ class FetchClientTokenUnitTest { coEvery { restClient.send(any()) } returns successResponse // When - val result = sut() + val result = sut.createLowScopedAccessToken() // Then - assertEquals(expectedToken, result) + assertTrue(result is CreateLowScopedAccessTokenResult.Success) + assertEquals(expectedToken, (result as CreateLowScopedAccessTokenResult.Success).token) } @Test - fun `invoke() throws APIClientError when response is not successful`() = runTest { + fun `createLowScopedAccessToken() returns failure when response is not successful`() = runTest { // Given val errorResponse = HttpResponse( status = 401, @@ -89,14 +93,15 @@ class FetchClientTokenUnitTest { ) coEvery { restClient.send(any()) } returns errorResponse - // When/Then - val result = runCatching { sut() } - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is PayPalSDKError) + // When + val result = sut.createLowScopedAccessToken() + + // Then + assertTrue(result is CreateLowScopedAccessTokenResult.Failure) } @Test - fun `invoke() throws APIClientError when response body is null`() = runTest { + fun `createLowScopedAccessToken() returns failure when response body is null`() = runTest { // Given val successResponse = HttpResponse( status = 200, @@ -104,14 +109,15 @@ class FetchClientTokenUnitTest { ) coEvery { restClient.send(any()) } returns successResponse - // When/Then - val result = runCatching { sut() } - assertTrue(result.isFailure) - // Expected JSONException when parsing null body + // When + val result = sut.createLowScopedAccessToken() + + // Then + assertTrue(result is CreateLowScopedAccessTokenResult.Failure) } @Test - fun `invoke() throws JSONException when response is not valid JSON`() = runTest { + fun `createLowScopedAccessToken() returns failure when response is not valid JSON`() = runTest { // Given val successResponse = HttpResponse( status = 200, @@ -119,14 +125,17 @@ class FetchClientTokenUnitTest { ) coEvery { restClient.send(any()) } returns successResponse - // When/Then - val result = runCatching { sut() } - assertTrue(result.isFailure) - // Expected JSONException for invalid JSON + // When + val result = sut.createLowScopedAccessToken() + + // Then + assertTrue(result is CreateLowScopedAccessTokenResult.Failure) + // Expected JSONException wrapped in PayPalSDKError } @Test - fun `invoke() throws JSONException when access_token is missing from response`() = runTest { + fun `createLowScopedAccessToken() returns failure when access_token field is missing from successful response`() = + runTest { // Given val successResponse = HttpResponse( status = 200, @@ -134,14 +143,18 @@ class FetchClientTokenUnitTest { ) coEvery { restClient.send(any()) } returns successResponse - // When/Then - val result = runCatching { sut() } - assertTrue(result.isFailure) - // Expected JSONException for missing access_token + // When + val result = sut.createLowScopedAccessToken() + + // Then + assertTrue(result is CreateLowScopedAccessTokenResult.Failure) + val error = (result as CreateLowScopedAccessTokenResult.Failure).error + assertTrue(error.errorDescription.contains("Missing access_token in response")) } @Test - fun `invoke() handles empty response body with proper error message`() = runTest { + fun `createLowScopedAccessToken() handles empty response body with proper error message`() = + runTest { // Given val errorResponse = HttpResponse( status = 500, @@ -149,19 +162,20 @@ class FetchClientTokenUnitTest { ) coEvery { restClient.send(any()) } returns errorResponse - // When/Then - try { - sut() - assert(false) { "Expected PayPalSDKError to be thrown" } - } catch (e: PayPalSDKError) { - val errorMessage = e.errorDescription - // Just verify it contains some error information - assert(errorMessage.contains("Error fetching client token")) - } + // When + val result = sut.createLowScopedAccessToken() + + // Then + assertTrue(result is CreateLowScopedAccessTokenResult.Failure) + val errorMessage = + (result as CreateLowScopedAccessTokenResult.Failure).error.errorDescription + // The error description will be the empty body from the HTTP response + assertEquals("", errorMessage) } @Test - fun `invoke() includes error message in exception when available`() = runTest { + fun `createLowScopedAccessToken() includes error message in exception when available`() = + runTest { // Given val errorMessage = "Server temporarily unavailable" val errorResponse = HttpResponse( @@ -170,23 +184,24 @@ class FetchClientTokenUnitTest { ) coEvery { restClient.send(any()) } returns errorResponse - // When/Then - try { - sut() - assert(false) { "Expected PayPalSDKError to be thrown" } - } catch (e: PayPalSDKError) { - val description = e.errorDescription - // Just verify it contains some error information - assert(description.contains("Error fetching client token")) - } + // When + val result = sut.createLowScopedAccessToken() + + // Then + assertTrue(result is CreateLowScopedAccessTokenResult.Failure) + val description = + (result as CreateLowScopedAccessTokenResult.Failure).error.errorDescription + // The error description will be the error message from the HTTP response body + assertEquals(errorMessage, description) } @Test - fun `invoke() creates proper Basic Auth header from client ID`() = runTest { + fun `createLowScopedAccessToken() creates proper Basic Auth header from client ID`() = runTest { // Given val clientId = "test-client-123" val configWithCustomClientId = CoreConfig(clientId, Environment.LIVE) - val sutWithCustomConfig = FetchClientToken(configWithCustomClientId, restClient) + val sutWithCustomConfig = + AuthenticationSecureTokenServiceAPI(configWithCustomClientId, restClient) val successResponse = HttpResponse( status = 200, @@ -197,7 +212,7 @@ class FetchClientTokenUnitTest { val requestSlot = slot() // When - sutWithCustomConfig() + sutWithCustomConfig.createLowScopedAccessToken() // Then coVerify { restClient.send(capture(requestSlot)) } @@ -207,8 +222,9 @@ class FetchClientTokenUnitTest { // Decode and verify the Basic auth contains the client ID val encodedCredentials = authHeader.substring("Basic ".length) - val decodedCredentials = - String(android.util.Base64.decode(encodedCredentials, android.util.Base64.DEFAULT)) - assertEquals("$clientId:", decodedCredentials) + val decodedClientId = + android.util.Base64.decode(encodedCredentials, android.util.Base64.DEFAULT) + .decodeToString() + assertEquals(clientId, decodedClientId) } } diff --git a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutCallback.kt b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutCallback.kt new file mode 100644 index 000000000..89e42edec --- /dev/null +++ b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutCallback.kt @@ -0,0 +1,13 @@ +package com.paypal.android.paypalwebpayments + +import androidx.annotation.MainThread + +fun interface PayPalWebCheckoutCallback { + /** + * Called when the PayPal web checkout operation completes. + * + * @param result [PayPalPresentAuthChallengeResult] result with details + */ + @MainThread + fun onPayPalWebCheckoutResult(result: PayPalPresentAuthChallengeResult) +} diff --git a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt index 5dbbd7669..dfcadf817 100644 --- a/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt +++ b/PayPalWebPayments/src/main/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClient.kt @@ -6,10 +6,15 @@ import androidx.activity.ComponentActivity import com.paypal.android.corepayments.CoreConfig import com.paypal.android.corepayments.RestClient import com.paypal.android.corepayments.analytics.AnalyticsService -import com.paypal.android.corepayments.api.FetchClientToken +import com.paypal.android.corepayments.api.AuthenticationSecureTokenServiceAPI +import com.paypal.android.corepayments.api.CreateLowScopedAccessTokenResult import com.paypal.android.paypalwebpayments.analytics.CheckoutEvent import com.paypal.android.paypalwebpayments.analytics.PayPalWebAnalytics import com.paypal.android.paypalwebpayments.analytics.VaultEvent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch // NEXT MAJOR VERSION: consider renaming this module to PayPalWebClient since // it now offers both checkout and vaulting @@ -20,7 +25,8 @@ import com.paypal.android.paypalwebpayments.analytics.VaultEvent class PayPalWebCheckoutClient internal constructor( private val analytics: PayPalWebAnalytics, private val payPalWebLauncher: PayPalWebLauncher, - private val fetchClientToken: FetchClientToken + private val authenticationSecureTokenServiceAPI: AuthenticationSecureTokenServiceAPI, + private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main ) { // for analytics tracking @@ -37,7 +43,7 @@ class PayPalWebCheckoutClient internal constructor( constructor(context: Context, configuration: CoreConfig, urlScheme: String) : this( PayPalWebAnalytics(AnalyticsService(context.applicationContext, configuration)), PayPalWebLauncher(urlScheme, configuration), - FetchClientToken(configuration, RestClient(configuration)) + AuthenticationSecureTokenServiceAPI(configuration, RestClient(configuration)) ) /** @@ -52,21 +58,51 @@ class PayPalWebCheckoutClient internal constructor( checkoutOrderId = request.orderId analytics.notify(CheckoutEvent.STARTED, checkoutOrderId) - // Fetch client token for authentication - fetchClientToken() - // todo: token will be used while fetching app switch eligibility + val tokenResult = authenticationSecureTokenServiceAPI.createLowScopedAccessToken() + return when (tokenResult) { + is CreateLowScopedAccessTokenResult.Failure -> { + analytics.notify(CheckoutEvent.AUTH_CHALLENGE_PRESENTATION_FAILED, checkoutOrderId) + PayPalPresentAuthChallengeResult.Failure(tokenResult.error) + } - val result = payPalWebLauncher.launchPayPalWebCheckout(activity, request) - when (result) { - is PayPalPresentAuthChallengeResult.Success -> analytics.notify( - CheckoutEvent.AUTH_CHALLENGE_PRESENTATION_SUCCEEDED, - checkoutOrderId - ) + is CreateLowScopedAccessTokenResult.Success -> { + // todo: token will be used while fetching app switch eligibility + val result = payPalWebLauncher.launchPayPalWebCheckout(activity, request) + when (result) { + is PayPalPresentAuthChallengeResult.Success -> analytics.notify( + CheckoutEvent.AUTH_CHALLENGE_PRESENTATION_SUCCEEDED, + checkoutOrderId + ) + + is PayPalPresentAuthChallengeResult.Failure -> + analytics.notify( + CheckoutEvent.AUTH_CHALLENGE_PRESENTATION_FAILED, + checkoutOrderId + ) + } + result + } + } + } - is PayPalPresentAuthChallengeResult.Failure -> - analytics.notify(CheckoutEvent.AUTH_CHALLENGE_PRESENTATION_FAILED, checkoutOrderId) + /** + * Confirm PayPal payment source for an order with callback. + * Network operations are handled automatically by the Http layer. + * + * @param activity the ComponentActivity to launch the auth challenge from + * @param request [PayPalWebCheckoutRequest] for requesting an order approval + * @param callback callback to receive the result + */ + fun start( + activity: ComponentActivity, + request: PayPalWebCheckoutRequest, + callback: PayPalWebCheckoutCallback + ) { + CoroutineScope(mainDispatcher).launch { + callback.onPayPalWebCheckoutResult( + start(activity, request) + ) } - return result } /** diff --git a/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt b/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt index 9a4ef82c5..6ebf43fac 100644 --- a/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt +++ b/PayPalWebPayments/src/test/java/com/paypal/android/paypalwebpayments/PayPalWebCheckoutClientUnitTest.kt @@ -2,9 +2,9 @@ package com.paypal.android.paypalwebpayments import android.content.Intent import androidx.fragment.app.FragmentActivity -import com.paypal.android.corepayments.APIClientError import com.paypal.android.corepayments.PayPalSDKError -import com.paypal.android.corepayments.api.FetchClientToken +import com.paypal.android.corepayments.api.AuthenticationSecureTokenServiceAPI +import com.paypal.android.corepayments.api.CreateLowScopedAccessTokenResult import com.paypal.android.paypalwebpayments.analytics.PayPalWebAnalytics import io.mockk.coEvery import io.mockk.coVerify @@ -26,7 +26,7 @@ class PayPalWebCheckoutClientUnitTest { private val activity: FragmentActivity = mockk(relaxed = true) private val analytics = mockk(relaxed = true) - private lateinit var fetchClientToken: FetchClientToken + private lateinit var authenticationSecureTokenServiceAPI: AuthenticationSecureTokenServiceAPI private val intent = Intent() @@ -36,11 +36,16 @@ class PayPalWebCheckoutClientUnitTest { @Before fun beforeEach() { payPalWebLauncher = mockk(relaxed = true) - fetchClientToken = mockk(relaxed = true) - sut = PayPalWebCheckoutClient(analytics, payPalWebLauncher, fetchClientToken) + authenticationSecureTokenServiceAPI = mockk(relaxed = true) + sut = PayPalWebCheckoutClient( + analytics, + payPalWebLauncher, + authenticationSecureTokenServiceAPI + ) // Mock successful token fetch by default - coEvery { fetchClientToken() } returns "fake-access-token" + coEvery { authenticationSecureTokenServiceAPI.createLowScopedAccessToken() } returns + CreateLowScopedAccessTokenResult.Success("fake-access-token") } @Test @@ -51,7 +56,7 @@ class PayPalWebCheckoutClientUnitTest { val request = PayPalWebCheckoutRequest("fake-order-id") sut.start(activity, request) - coVerify(exactly = 1) { fetchClientToken() } + coVerify(exactly = 1) { authenticationSecureTokenServiceAPI.createLowScopedAccessToken() } verify(exactly = 1) { payPalWebLauncher.launchPayPalWebCheckout(activity, request) } } @@ -67,26 +72,28 @@ class PayPalWebCheckoutClientUnitTest { } @Test - fun `start() propagates FetchClientToken failure`() = runTest { - val tokenError = APIClientError.payPalCheckoutError("Token fetch failed") - coEvery { fetchClientToken() } throws tokenError + fun `start() returns failure when FetchClientToken fails`() = runTest { + val tokenError = PayPalSDKError(401, "Token fetch failed") + coEvery { authenticationSecureTokenServiceAPI.createLowScopedAccessToken() } returns + CreateLowScopedAccessTokenResult.Failure(tokenError) val request = PayPalWebCheckoutRequest("fake-order-id") - val result = runCatching { sut.start(activity, request) } - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is PayPalSDKError) + val result = sut.start(activity, request) + assertTrue(result is PayPalPresentAuthChallengeResult.Failure) + assertSame(tokenError, (result as PayPalPresentAuthChallengeResult.Failure).error) } @Test fun `start() does not call launcher when token fetch fails`() = runTest { - val tokenError = APIClientError.payPalCheckoutError("Token fetch failed") - coEvery { fetchClientToken() } throws tokenError + val tokenError = PayPalSDKError(401, "Token fetch failed") + coEvery { authenticationSecureTokenServiceAPI.createLowScopedAccessToken() } returns + CreateLowScopedAccessTokenResult.Failure(tokenError) val request = PayPalWebCheckoutRequest("fake-order-id") - val result = runCatching { sut.start(activity, request) } - assertTrue(result.isFailure) + val result = sut.start(activity, request) + assertTrue(result is PayPalPresentAuthChallengeResult.Failure) verify(exactly = 0) { payPalWebLauncher.launchPayPalWebCheckout(any(), any()) } }