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)) + } +}