diff --git a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/di/ExploreDashModule.kt b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/di/ExploreDashModule.kt
index 45850b1f73..6c14fda73a 100644
--- a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/di/ExploreDashModule.kt
+++ b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/di/ExploreDashModule.kt
@@ -67,7 +67,7 @@ abstract class ExploreDashModule {
@Provides
fun provideCTXAuthApi(remoteDataSource: RemoteDataSource): CTXSpendTokenApi {
- return remoteDataSource.buildApi(CTXSpendTokenApi::class.java)
+ return remoteDataSource.buildTokenApi()
}
@Provides
diff --git a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/RemoteDataSource.kt b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/RemoteDataSource.kt
index eb0e49824b..7c4b6f157b 100644
--- a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/RemoteDataSource.kt
+++ b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/RemoteDataSource.kt
@@ -58,7 +58,7 @@ class RemoteDataSource @Inject constructor(
.create(api)
}
- private fun buildTokenApi(): CTXSpendTokenApi {
+ fun buildTokenApi(): CTXSpendTokenApi {
return Retrofit.Builder()
.baseUrl(
if (walletData.networkParameters.id == NetworkParameters.ID_MAINNET) {
@@ -67,15 +67,18 @@ class RemoteDataSource @Inject constructor(
CTXSpendConstants.DEV_BASE_URL
}
)
- .client(getOkHttpClient())
+ .client(getOkHttpClient(includeAuthorization = false))
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(CTXSpendTokenApi::class.java)
}
- private fun getOkHttpClient(authenticator: Authenticator? = null): OkHttpClient {
+ private fun getOkHttpClient(
+ authenticator: Authenticator? = null,
+ includeAuthorization: Boolean = true
+ ): OkHttpClient {
return OkHttpClient.Builder()
- .addInterceptor(HeadersInterceptor(config))
+ .addInterceptor(HeadersInterceptor(config, includeAuthorization))
.addInterceptor(ErrorHandlingInterceptor(ServiceName.CTXSpend))
.connectTimeout(20.seconds.toJavaDuration())
.callTimeout(20.seconds.toJavaDuration())
diff --git a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/authenticator/TokenAuthenticator.kt b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/authenticator/TokenAuthenticator.kt
index f4afa26299..0b28112103 100644
--- a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/authenticator/TokenAuthenticator.kt
+++ b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/authenticator/TokenAuthenticator.kt
@@ -16,9 +16,11 @@
*/
package org.dash.wallet.features.exploredash.network.authenticator
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
@@ -27,6 +29,7 @@ import org.dash.wallet.features.exploredash.data.dashspend.ctx.model.RefreshToke
import org.dash.wallet.features.exploredash.data.dashspend.ctx.model.RefreshTokenResponse
import org.dash.wallet.features.exploredash.network.service.ctxspend.CTXSpendTokenApi
import org.dash.wallet.features.exploredash.utils.CTXSpendConfig
+import retrofit2.HttpException
import javax.inject.Inject
class TokenAuthenticator @Inject constructor(
@@ -34,39 +37,92 @@ class TokenAuthenticator @Inject constructor(
private val config: CTXSpendConfig
) : Authenticator {
- // For multiple call to refresh token sync
- private val tokenMutex = Mutex()
+ companion object {
+ // Shared across every TokenAuthenticator instance (the OkHttp retry path plus the
+ // DI/factory-built instances) so concurrent refreshes are serialized against the
+ // single shared token store. Without this, a refresh that loses the race could
+ // receive a 401 for an already-rotated refresh token and clear tokens that another
+ // caller just saved.
+ private val tokenMutex = Mutex()
+ }
override fun authenticate(route: Route?, response: Response): Request? {
+ if (response.responseCount >= 2) {
+ return null
+ }
+
return runBlocking {
- tokenMutex.withLock {
- try {
- val tokenResponse = getUpdatedToken()
- tokenResponse?.let {
- config.setSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN, it.accessToken ?: "")
- config.setSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN, it.refreshToken ?: "")
- if (it.refreshToken != null) {
- config.set(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME, System.currentTimeMillis())
- } else {
- config.set(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME, 0L)
- }
- response.request.newBuilder()
- .header("Authorization", "Bearer ${it.accessToken}")
- .build()
- }
- } catch (e: Exception) {
- config.setSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN, "")
- config.setSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN, "")
- config.set(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN_TIME, 0L)
- config.set(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME, 0L)
- null
- }
+ refreshAccessToken(response.request.accessToken)
+ ?.let { response.request.withAccessToken(it) }
+ }
+ }
+
+ /**
+ * Refreshes the access token under a process-wide lock and persists the result, so the
+ * OkHttp retry path and explicit [CTXSpendRepository.refreshToken] calls never issue
+ * overlapping refreshes.
+ *
+ * @param staleAccessToken the rejected access token, when known (the OkHttp retry path).
+ * If another caller already rotated the token, the freshly stored token is returned
+ * without a network call. Pass null to force a refresh.
+ * @return the valid access token, or null when the refresh token was rejected (state is
+ * cleared) or the refresh failed transiently (state is preserved).
+ */
+ suspend fun refreshAccessToken(staleAccessToken: String? = null): String? = tokenMutex.withLock {
+ val currentAccessToken = config.getSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN)
+
+ if (staleAccessToken != null && !currentAccessToken.isNullOrBlank() && currentAccessToken != staleAccessToken) {
+ return@withLock currentAccessToken
+ }
+
+ try {
+ withContext(NonCancellable) {
+ val tokenResponse = getUpdatedToken()
+ val accessToken = tokenResponse?.accessToken?.takeIf { it.isNotBlank() }
+ ?: return@withContext null
+
+ config.saveTokenState(accessToken, tokenResponse.refreshToken)
+ accessToken
+ }
+ } catch (e: Exception) {
+ // Only a genuine refresh-token rejection drops the session. The refresh endpoint
+ // returns HTTP 401 for that case, which ErrorHandlingInterceptor's /refresh-token
+ // passthrough surfaces as an HttpException; any other failure is treated as
+ // transient and the tokens are kept so the user stays signed in.
+ if (e.isRefreshTokenRejected()) {
+ config.clearTokenState()
}
+ null
}
}
- suspend fun getUpdatedToken(): RefreshTokenResponse? {
+ private suspend fun getUpdatedToken(): RefreshTokenResponse? {
val refreshToken = config.getSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN) ?: ""
return tokenApi.refreshToken(RefreshTokenRequest(refreshToken = refreshToken))
}
+
+ private val Response.responseCount: Int
+ get() {
+ var result = 1
+ var priorResponse = this.priorResponse
+ while (priorResponse != null) {
+ result++
+ priorResponse = priorResponse.priorResponse
+ }
+ return result
+ }
+
+ private val Request.accessToken: String?
+ get() = header("Authorization")
+ ?.takeIf { it.startsWith("Bearer ") }
+ ?.removePrefix("Bearer ")
+ ?.takeIf { it.isNotBlank() }
+
+ private fun Request.withAccessToken(accessToken: String): Request =
+ newBuilder()
+ .header("Authorization", "Bearer $accessToken")
+ .build()
+
+ private fun Exception.isRefreshTokenRejected(): Boolean =
+ this is HttpException && code() == 401
}
diff --git a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/interceptor/HeadersInterceptor.kt b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/interceptor/HeadersInterceptor.kt
index b216effc81..042991c7a3 100644
--- a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/interceptor/HeadersInterceptor.kt
+++ b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/interceptor/HeadersInterceptor.kt
@@ -23,7 +23,10 @@ import org.dash.wallet.features.exploredash.utils.CTXSpendConfig
import org.dash.wallet.features.exploredash.utils.CTXSpendConstants
import javax.inject.Inject
-class HeadersInterceptor @Inject constructor(private val config: CTXSpendConfig) : Interceptor {
+class HeadersInterceptor @Inject constructor(
+ private val config: CTXSpendConfig,
+ private val includeAuthorization: Boolean = true
+) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
@@ -31,8 +34,12 @@ class HeadersInterceptor @Inject constructor(private val config: CTXSpendConfig)
requestBuilder.header("Accept", "application/json")
requestBuilder.header(CTXSpendConstants.CLIENT_ID_PARAM_NAME, CTXSpendConstants.CLIENT_ID)
- val accessToken = runBlocking {
- config.getSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN)
+ val accessToken = if (includeAuthorization) {
+ runBlocking {
+ config.getSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN)
+ }
+ } else {
+ null
}
if (accessToken?.isNotEmpty() == true) {
diff --git a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/repository/CTXSpendRepository.kt b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/repository/CTXSpendRepository.kt
index ad9fd9bf6f..1e4eaf87b3 100644
--- a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/repository/CTXSpendRepository.kt
+++ b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/repository/CTXSpendRepository.kt
@@ -122,11 +122,14 @@ class CTXSpendRepository @Inject constructor(
override suspend fun verifyEmail(code: String): Boolean {
val email = config.getSecuredData(CTXSpendConfig.PREFS_KEY_CTX_PAY_EMAIL)
val response = api.verifyEmail(VerifyEmailRequest(email = email!!, code = code))
- config.setSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN, response?.accessToken!!)
- config.setSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN, response.refreshToken!!)
- val time = System.currentTimeMillis()
- config.set(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME, time)
- config.set(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN_TIME, time)
+ val accessToken = response?.accessToken
+ val refreshToken = response?.refreshToken
+
+ if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) {
+ return false
+ }
+
+ config.saveTokenState(accessToken, refreshToken)
return config.getSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN)?.isNotEmpty() ?: false
}
@@ -138,10 +141,7 @@ class CTXSpendRepository @Inject constructor(
}
suspend fun reset() {
- config.setSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN, "")
- config.setSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN, "")
- config.set(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME, 0L)
- config.set(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN_TIME, 0L)
+ config.clearTokenState()
}
override suspend fun purchaseGiftCard(
@@ -237,10 +237,17 @@ class CTXSpendRepository @Inject constructor(
suspend fun checkToken(): Boolean {
val refreshTokenTime = config.get(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN_TIME)
- return if (refreshTokenTime == null) {
+ val refreshToken = config.getSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN)
+
+ return if (refreshToken.isNullOrBlank()) {
+ reset()
false
+ } else if (refreshTokenTime == null) {
+ refreshToken()
+ } else if ((System.currentTimeMillis() - refreshTokenTime) < REFRESH_TOKEN_EXPIRATION) {
+ true
} else {
- (System.currentTimeMillis() - refreshTokenTime) < REFRESH_TOKEN_EXPIRATION
+ refreshToken()
}
}
@@ -252,21 +259,10 @@ class CTXSpendRepository @Inject constructor(
}
override suspend fun refreshToken(): Boolean {
- return try {
- val tokenResponse = tokenAuthenticator.getUpdatedToken()
- tokenResponse?.let {
- config.setSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN, it.accessToken ?: "")
- config.setSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN, it.refreshToken ?: "")
- config.set(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME, System.currentTimeMillis())
- true
- } ?: false
- } catch (e: Exception) {
- config.setSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN, "")
- config.setSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN, "")
- config.set(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME, 0L)
- config.set(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN_TIME, 0L)
- false
- }
+ // Delegate to the authenticator so this shares the same process-wide lock and token
+ // persistence as the OkHttp retry path, avoiding overlapping refreshes that could
+ // clear a token another caller just rotated.
+ return tokenAuthenticator.refreshAccessToken() != null
}
suspend fun getCTXSpendEmail(): String? {
diff --git a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/repository/DashSpendRepositoryFactory.kt b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/repository/DashSpendRepositoryFactory.kt
index c85e5a0067..25955ff3de 100644
--- a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/repository/DashSpendRepositoryFactory.kt
+++ b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/repository/DashSpendRepositoryFactory.kt
@@ -23,7 +23,6 @@ import org.dash.wallet.features.exploredash.network.PiggyCardsRemoteDataSource
import org.dash.wallet.features.exploredash.network.RemoteDataSource
import org.dash.wallet.features.exploredash.network.authenticator.TokenAuthenticator
import org.dash.wallet.features.exploredash.network.service.ctxspend.CTXSpendApi
-import org.dash.wallet.features.exploredash.network.service.ctxspend.CTXSpendTokenApi
import org.dash.wallet.features.exploredash.network.service.piggycards.PiggyCardsApi
import org.dash.wallet.features.exploredash.utils.CTXSpendConfig
import org.dash.wallet.features.exploredash.utils.PiggyCardsConfig
@@ -46,7 +45,7 @@ class DashSpendRepositoryFactory @Inject constructor(
private fun createCTXSpend(): CTXSpendRepository {
val remoteDataSource = RemoteDataSource(ctxSpendConfig, walletDataProvider)
val api = remoteDataSource.buildApi(CTXSpendApi::class.java)
- val tokenApi = remoteDataSource.buildApi(CTXSpendTokenApi::class.java)
+ val tokenApi = remoteDataSource.buildTokenApi()
val tokenAuthenticator = TokenAuthenticator(tokenApi, ctxSpendConfig)
return CTXSpendRepository(api, ctxSpendConfig, tokenAuthenticator)
diff --git a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/ui/dashspend/DashSpendUserAuthFragment.kt b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/ui/dashspend/DashSpendUserAuthFragment.kt
index f87b821802..814aac3d41 100644
--- a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/ui/dashspend/DashSpendUserAuthFragment.kt
+++ b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/ui/dashspend/DashSpendUserAuthFragment.kt
@@ -40,6 +40,7 @@ import org.dash.wallet.common.util.safeNavigate
import org.dash.wallet.features.exploredash.R
import org.dash.wallet.features.exploredash.data.dashspend.GiftCardProviderType
import org.dash.wallet.features.exploredash.databinding.FragmentDashSpendUserAuthBinding
+import org.dash.wallet.features.exploredash.repository.CTXSpendException
import org.dash.wallet.features.exploredash.utils.exploreViewModels
import org.slf4j.LoggerFactory
import retrofit2.HttpException
@@ -240,25 +241,47 @@ class DashSpendUserAuthFragment : Fragment(R.layout.fragment_dash_spend_user_aut
if (success) {
viewModel.logEvent(AnalyticsConstants.DashSpend.SUCCESSFUL_LOGIN)
hideKeyboard()
- when (viewModel.selectedProvider) {
+ // Navigate based on the provider from nav args, not viewModel.selectedProvider:
+ // selectedProvider is non-persisted nav-graph ViewModel state that resets to null
+ // when the ViewModel is recreated (e.g. process death while the user leaves the app
+ // to read the emailed code). That previously crashed this success path and was
+ // mis-reported as an invalid code. Restore it so downstream screens still have it.
+ viewModel.selectedProvider = provider
+ when (provider) {
GiftCardProviderType.CTX -> safeNavigate(
DashSpendUserAuthFragmentDirections.authToPurchaseGiftCardFragment()
)
GiftCardProviderType.PiggyCards -> safeNavigate(
DashSpendUserAuthFragmentDirections.authToPurchaseGiftCardFragmentV2()
)
- else -> error("serious error. provider = null")
}
+ } else {
+ // A non-exception failure means the code was accepted but the backend returned
+ // no usable tokens - a server anomaly, not a wrong code.
+ viewModel.logEvent(AnalyticsConstants.DashSpend.UNSUCCESSFUL_LOGIN)
+ showVerifyError(getString(R.string.loading_error))
}
} catch (e: Exception) {
- binding.inputWrapper.isErrorEnabled = true
- binding.inputErrorTv.text = getString(R.string.invaild_code)
- binding.inputErrorTv.isVisible = true
+ log.error(
+ "DashSpend: verify code failed for ${provider.name}: ${e::class.simpleName} - ${e.message}",
+ e
+ )
+ viewModel.logEvent(AnalyticsConstants.DashSpend.UNSUCCESSFUL_LOGIN)
+ // Only a genuine rejection of the entered code (HTTP 400) is the user's fault;
+ // transient/server/transport errors get a generic message instead of "wrong code".
+ val isInvalidCode = e is CTXSpendException && e.errorCode == 400
+ showVerifyError(getString(if (isInvalidCode) R.string.invaild_code else R.string.loading_error))
}
hideLoading()
}
}
+ private fun showVerifyError(message: String) {
+ binding.inputWrapper.isErrorEnabled = true
+ binding.inputErrorTv.text = message
+ binding.inputErrorTv.isVisible = true
+ }
+
private fun isEmail(text: CharSequence?): Boolean {
return !text.isNullOrEmpty() && Patterns.EMAIL_ADDRESS.matcher(text).matches()
}
diff --git a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/utils/CTXSpendConfig.kt b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/utils/CTXSpendConfig.kt
index b1fb471760..0aa3f6bab2 100644
--- a/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/utils/CTXSpendConfig.kt
+++ b/features/exploredash/src/main/java/org/dash/wallet/features/exploredash/utils/CTXSpendConfig.kt
@@ -20,6 +20,8 @@ package org.dash.wallet.features.exploredash.utils
import android.content.Context
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.withContext
import org.dash.wallet.common.WalletDataProvider
import org.dash.wallet.common.data.BaseConfig
import org.dash.wallet.common.util.security.EncryptionProvider
@@ -43,4 +45,25 @@ class CTXSpendConfig @Inject constructor(
val PREFS_DEVICE_UUID = stringPreferencesKey("device_uuid")
val PREFS_LAST_PURCHASE_START = longPreferencesKey("last_purchase_start")
}
+
+ suspend fun saveTokenState(
+ accessToken: String,
+ refreshToken: String? = null,
+ now: Long = System.currentTimeMillis()
+ ) = withContext(NonCancellable) {
+ setSecuredData(PREFS_KEY_ACCESS_TOKEN, accessToken)
+ set(PREFS_KEY_ACCESS_TOKEN_TIME, now)
+
+ if (!refreshToken.isNullOrBlank()) {
+ setSecuredData(PREFS_KEY_REFRESH_TOKEN, refreshToken)
+ set(PREFS_KEY_REFRESH_TOKEN_TIME, now)
+ }
+ }
+
+ suspend fun clearTokenState() = withContext(NonCancellable) {
+ setSecuredData(PREFS_KEY_ACCESS_TOKEN, "")
+ setSecuredData(PREFS_KEY_REFRESH_TOKEN, "")
+ set(PREFS_KEY_ACCESS_TOKEN_TIME, 0L)
+ set(PREFS_KEY_REFRESH_TOKEN_TIME, 0L)
+ }
}
diff --git a/features/exploredash/src/test/java/org/dash/wallet/features/exploredash/CTXSpendRepositoryTokenTest.kt b/features/exploredash/src/test/java/org/dash/wallet/features/exploredash/CTXSpendRepositoryTokenTest.kt
new file mode 100644
index 0000000000..2aed35e4c7
--- /dev/null
+++ b/features/exploredash/src/test/java/org/dash/wallet/features/exploredash/CTXSpendRepositoryTokenTest.kt
@@ -0,0 +1,208 @@
+/*
+ * Copyright 2026 Dash Core Group.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.dash.wallet.features.exploredash
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.dash.wallet.common.WalletDataProvider
+import org.dash.wallet.common.util.security.EncryptionProvider
+import org.dash.wallet.features.exploredash.data.dashspend.ctx.model.RefreshTokenRequest
+import org.dash.wallet.features.exploredash.data.dashspend.ctx.model.RefreshTokenResponse
+import org.dash.wallet.features.exploredash.network.authenticator.TokenAuthenticator
+import org.dash.wallet.features.exploredash.network.service.ctxspend.CTXSpendApi
+import org.dash.wallet.features.exploredash.network.service.ctxspend.CTXSpendTokenApi
+import org.dash.wallet.features.exploredash.repository.CTXSpendRepository
+import org.dash.wallet.features.exploredash.utils.CTXSpendConfig
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.robolectric.RobolectricTestRunner
+import retrofit2.HttpException
+import retrofit2.Response
+import java.io.IOException
+import java.security.GeneralSecurityException
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class CTXSpendRepositoryTokenTest {
+ private lateinit var config: CTXSpendConfig
+
+ @Before
+ fun setup() = runTest {
+ val walletDataProvider = mock {
+ on { attachOnWalletWipedListener(any()) } doAnswer {}
+ }
+
+ config = CTXSpendConfig(
+ ApplicationProvider.getApplicationContext(),
+ walletDataProvider,
+ PlainTextEncryptionProvider
+ )
+ config.clearAll()
+ }
+
+ @After
+ fun tearDown() = runTest {
+ config.clearAll()
+ }
+
+ @Test
+ fun refreshTokenStoresRotatedTokenAfterCallerCancels() = runTest {
+ config.saveTokenState("old-access", "old-refresh", now = 1L)
+ val tokenApi = BlockingTokenApi(RefreshTokenResponse("new-refresh", "new-access"))
+ val repository = createRepository(tokenApi)
+
+ val refreshJob = launch {
+ repository.refreshToken()
+ }
+
+ tokenApi.started.await()
+ refreshJob.cancel()
+ tokenApi.complete()
+ refreshJob.join()
+
+ assertEquals("new-access", config.getSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN))
+ assertEquals("new-refresh", config.getSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN))
+ assertTrue((config.get(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME) ?: 0L) > 1L)
+ assertTrue((config.get(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN_TIME) ?: 0L) > 1L)
+ }
+
+ @Test
+ fun refreshTokenPreservesTokensOnTransientFailure() = runTest {
+ config.saveTokenState("old-access", "old-refresh", now = 1L)
+ val repository = createRepository(FailingTokenApi(IOException("offline")))
+
+ assertFalse(repository.refreshToken())
+
+ assertEquals("old-access", config.getSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN))
+ assertEquals("old-refresh", config.getSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN))
+ assertEquals(1L, config.get(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME))
+ assertEquals(1L, config.get(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN_TIME))
+ }
+
+ @Test
+ fun refreshTokenClearsTokensWhenRefreshTokenIsRejected() = runTest {
+ config.saveTokenState("old-access", "old-refresh", now = 1L)
+ val repository = createRepository(FailingTokenApi(http401()))
+
+ assertFalse(repository.refreshToken())
+
+ assertEquals("", config.getSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN))
+ assertEquals("", config.getSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN))
+ assertEquals(0L, config.get(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN_TIME))
+ assertEquals(0L, config.get(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN_TIME))
+ }
+
+ @Test
+ fun refreshAccessTokenReturnsRotatedTokenWithoutNetworkWhenAnotherCallerRefreshed() = runTest {
+ // Another caller already rotated the token; the failed request still carries the old one.
+ config.saveTokenState("fresh-access", "fresh-refresh", now = 1L)
+ // Throws if the network is hit - the rotated token should be returned without refreshing.
+ val authenticator = TokenAuthenticator(
+ FailingTokenApi(IllegalStateException("refresh endpoint must not be called")),
+ config
+ )
+
+ val result = authenticator.refreshAccessToken(staleAccessToken = "stale-access")
+
+ assertEquals("fresh-access", result)
+ assertEquals("fresh-refresh", config.getSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN))
+ }
+
+ @Test
+ fun verifyEmailReturnsFalseWhenServerOmitsTokens() = runTest {
+ config.setSecuredData(CTXSpendConfig.PREFS_KEY_CTX_PAY_EMAIL, "user@example.com")
+ // A 2xx response with no tokens must not crash (no !!) and must not persist a session.
+ val api = mock {
+ onBlocking { verifyEmail(any()) } doReturn RefreshTokenResponse(refreshToken = null, accessToken = null)
+ }
+ val repository = CTXSpendRepository(
+ api,
+ config,
+ TokenAuthenticator(FailingTokenApi(IOException("must not be called")), config)
+ )
+
+ assertFalse(repository.verifyEmail("123456"))
+ assertTrue(config.getSecuredData(CTXSpendConfig.PREFS_KEY_ACCESS_TOKEN).isNullOrEmpty())
+ assertTrue(config.getSecuredData(CTXSpendConfig.PREFS_KEY_REFRESH_TOKEN).isNullOrEmpty())
+ }
+
+ private fun createRepository(tokenApi: CTXSpendTokenApi): CTXSpendRepository {
+ return CTXSpendRepository(
+ mock(),
+ config,
+ TokenAuthenticator(tokenApi, config)
+ )
+ }
+
+ private class BlockingTokenApi(
+ private val response: RefreshTokenResponse
+ ) : CTXSpendTokenApi {
+ val started = CompletableDeferred()
+ private val canComplete = CompletableDeferred()
+
+ override suspend fun refreshToken(refreshTokenRequest: RefreshTokenRequest): RefreshTokenResponse {
+ started.complete(Unit)
+ canComplete.await()
+ return response
+ }
+
+ fun complete() {
+ canComplete.complete(Unit)
+ }
+ }
+
+ private class FailingTokenApi(
+ private val error: Exception
+ ) : CTXSpendTokenApi {
+ override suspend fun refreshToken(refreshTokenRequest: RefreshTokenRequest): RefreshTokenResponse {
+ throw error
+ }
+ }
+
+ private object PlainTextEncryptionProvider : EncryptionProvider {
+ @Throws(GeneralSecurityException::class, IOException::class)
+ override fun encrypt(keyAlias: String, textToEncrypt: String): ByteArray =
+ textToEncrypt.toByteArray()
+
+ @Throws(GeneralSecurityException::class, IOException::class)
+ override fun decrypt(keyAlias: String, encryptedData: ByteArray): String =
+ encryptedData.toString(Charsets.UTF_8)
+
+ override fun deleteKey(keyAlias: String) = Unit
+ }
+
+ private fun http401(): HttpException {
+ val body = "{}".toResponseBody("application/json".toMediaType())
+ return HttpException(Response.error(401, body))
+ }
+}