Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand All @@ -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,
Expand All @@ -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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
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.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.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 AuthenticationSecureTokenServiceAPIUnitTest {

private lateinit var restClient: RestClient
private lateinit var coreConfig: CoreConfig
private lateinit var sut: AuthenticationSecureTokenServiceAPI

@Before
fun beforeEach() {
coreConfig = CoreConfig("test-client-id", Environment.SANDBOX)
restClient = mockk(relaxed = true)
sut = AuthenticationSecureTokenServiceAPI(coreConfig, restClient)
}

@Test
fun `createLowScopedAccessToken() 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<APIRequest>()

// When
val result = sut.createLowScopedAccessToken()

// 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 "))

assertTrue(result is CreateLowScopedAccessTokenResult.Success)
assertEquals("test-token", (result as CreateLowScopedAccessTokenResult.Success).token)
}

@Test
fun `createLowScopedAccessToken() 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.createLowScopedAccessToken()

// Then
assertTrue(result is CreateLowScopedAccessTokenResult.Success)
assertEquals(expectedToken, (result as CreateLowScopedAccessTokenResult.Success).token)
}

@Test
fun `createLowScopedAccessToken() returns failure 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
val result = sut.createLowScopedAccessToken()

// Then
assertTrue(result is CreateLowScopedAccessTokenResult.Failure)
}

@Test
fun `createLowScopedAccessToken() returns failure when response body is null`() = runTest {
// Given
val successResponse = HttpResponse(
status = 200,
body = null
)
coEvery { restClient.send(any()) } returns successResponse

// When
val result = sut.createLowScopedAccessToken()

// Then
assertTrue(result is CreateLowScopedAccessTokenResult.Failure)
}

@Test
fun `createLowScopedAccessToken() returns failure when response is not valid JSON`() = runTest {
// Given
val successResponse = HttpResponse(
status = 200,
body = "invalid-json-response"
)
coEvery { restClient.send(any()) } returns successResponse

// When
val result = sut.createLowScopedAccessToken()

// Then
assertTrue(result is CreateLowScopedAccessTokenResult.Failure)
// Expected JSONException wrapped in PayPalSDKError
}

@Test
fun `createLowScopedAccessToken() returns failure when access_token field is missing from successful response`() =
runTest {
// Given
val successResponse = HttpResponse(
status = 200,
body = """{"token_type": "Bearer", "expires_in": 3600}"""
)
coEvery { restClient.send(any()) } returns successResponse

// 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 `createLowScopedAccessToken() handles empty response body with proper error message`() =
runTest {
// Given
val errorResponse = HttpResponse(
status = 500,
body = ""
)
coEvery { restClient.send(any()) } returns errorResponse

// 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 `createLowScopedAccessToken() 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
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 `createLowScopedAccessToken() creates proper Basic Auth header from client ID`() = runTest {
// Given
val clientId = "test-client-123"
val configWithCustomClientId = CoreConfig(clientId, Environment.LIVE)
val sutWithCustomConfig =
AuthenticationSecureTokenServiceAPI(configWithCustomClientId, restClient)

val successResponse = HttpResponse(
status = 200,
body = """{"access_token": "token", "token_type": "Bearer"}"""
)
coEvery { restClient.send(any()) } returns successResponse

val requestSlot = slot<APIRequest>()

// When
sutWithCustomConfig.createLowScopedAccessToken()

// 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 decodedClientId =
android.util.Base64.decode(encodedCredentials, android.util.Base64.DEFAULT)
.decodeToString()
assertEquals(clientId, decodedClientId)
}
}
Loading