Skip to content
Open
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 @@ -58,7 +58,7 @@ class PiggyCardsRemoteDataSource @Inject constructor(
.create(api)
}

private fun buildTokenApi(): PiggyCardsTokenApi {
fun buildTokenApi(): PiggyCardsTokenApi {
return Retrofit.Builder()
.baseUrl(
if (walletData.networkParameters.id == NetworkParameters.ID_MAINNET) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.LoginRequest
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.LoginResponse
import org.dash.wallet.features.exploredash.network.service.piggycards.PiggyCardsTokenApi
import org.dash.wallet.features.exploredash.repository.CTXSpendException
import org.dash.wallet.features.exploredash.utils.PiggyCardsConfig
import org.slf4j.LoggerFactory
import java.time.LocalDateTime
Expand All @@ -39,53 +39,79 @@ class PiggyCardsAuthenticator @Inject constructor(

companion object {
private val log = LoggerFactory.getLogger(PiggyCardsAuthenticator::class.java)
}

// For multiple call to refresh token sync
private val tokenMutex = Mutex()
// Shared across every PiggyCardsAuthenticator instance (the OkHttp retry path plus the
// factory-built instance used by the repository) so concurrent re-logins are serialized
// against the single shared token store, instead of racing on separate per-instance locks.
private val tokenMutex = Mutex()
}

override fun authenticate(route: Route?, response: Response): Request? {
if (response.responseCount >= 2) {
return null
}

return runBlocking {
tokenMutex.withLock {
try {
val loginResponse = refreshToken()
if (loginResponse != null) {
handleLoginResponse(loginResponse)
response.request.newBuilder()
.header("Authorization", "Bearer ${loginResponse.accessToken}")
.build()
} else {
null
}
} catch (e: Exception) {
log.error("Failed to refresh token: ${e.message}", e)
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_ACCESS_TOKEN, "")
null
}
reLogin()?.let { accessToken ->
response.request.newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
}
}
}

private suspend fun refreshToken(): LoginResponse? {
/**
* Re-authenticates with the stored credentials under a process-wide lock and persists the new
* access token, so the OkHttp retry path and [org.dash.wallet.features.exploredash.repository.PiggyCardsRepository]
* never issue overlapping logins.
*
* @return the new access token, or null when no credentials are stored or the login failed.
* Stored credentials are preserved on transient failures so the session survives; only a
* genuine rejection (HTTP 401) clears the cached access token.
*/
suspend fun reLogin(): String? = tokenMutex.withLock {
val userId = config.getSecuredData(PiggyCardsConfig.PREFS_KEY_USER_ID)
val password = config.getSecuredData(PiggyCardsConfig.PREFS_KEY_PASSWORD)

return if (!userId.isNullOrBlank() && !password.isNullOrBlank()) {
tokenApi.login(LoginRequest(userId = userId, password = password))
} else {
if (userId.isNullOrBlank() || password.isNullOrBlank()) {
return@withLock null
}

try {
val response = tokenApi.login(LoginRequest(userId = userId, password = password))
persistLogin(response.accessToken, response.expiresIn)
response.accessToken.takeIf { it.isNotBlank() }
Comment on lines +81 to +83

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate the access token before persisting it.

persistLogin() runs even when response.accessToken is blank, so a malformed successful response can overwrite the cached token/expiry while reLogin() returns null. Persist only after the token passes the non-blank check.

Proposed fix
-            val response = tokenApi.login(LoginRequest(userId = userId, password = password))
-            persistLogin(response.accessToken, response.expiresIn)
-            response.accessToken.takeIf { it.isNotBlank() }
+            val response = tokenApi.login(LoginRequest(userId = userId, password = password))
+            val accessToken = response.accessToken.takeIf { it.isNotBlank() } ?: return@withLock null
+            persistLogin(accessToken, response.expiresIn)
+            accessToken
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val response = tokenApi.login(LoginRequest(userId = userId, password = password))
persistLogin(response.accessToken, response.expiresIn)
response.accessToken.takeIf { it.isNotBlank() }
val response = tokenApi.login(LoginRequest(userId = userId, password = password))
val accessToken = response.accessToken.takeIf { it.isNotBlank() } ?: return@withLock null
persistLogin(accessToken, response.expiresIn)
accessToken
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/authenticator/PiggyCardsAuthenticator.kt`
around lines 81 - 83, The issue is that in the login method, persistLogin() is
being called before validating that response.accessToken is not blank, which
means invalid or blank tokens can overwrite cached credentials. Fix this by
reordering the logic so that the token is validated for being non-blank first,
and then persistLogin() is only called if the token passes that validation
check. The takeIf { it.isNotBlank() } check should be performed before the
persistLogin() call, not after it.

} catch (e: Exception) {
log.error("Failed to refresh token: ${e.message}", e)
// Only a genuine credential rejection drops the cached token. The login endpoint
// returns HTTP 401 for that case, surfaced by ErrorHandlingInterceptor as a
// CTXSpendException; any other failure is transient, so the token is kept and the
// next request retries instead of forcing the user to sign in again.
if (e is CTXSpendException && e.errorCode == 401) {
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_ACCESS_TOKEN, "")
}
null
}
Comment on lines +84 to 94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/authenticator/PiggyCardsAuthenticator.kt | head -120

Repository: dashpay/dash-wallet

Length of output: 5811


🏁 Script executed:

rg "CancellationException" features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/authenticator/PiggyCardsAuthenticator.kt

Repository: dashpay/dash-wallet

Length of output: 45


🏁 Script executed:

rg "import.*CancellationException" features/exploredash/

Repository: dashpay/dash-wallet

Length of output: 45


🏁 Script executed:

cd features/exploredash && find . -name "*.kt" | head -5 && cd - > /dev/null

Repository: dashpay/dash-wallet

Length of output: 470


🏁 Script executed:

find . -name ".editorconfig" -o -name "ktlint.yml" -o -name "ktlint.yaml" | head -10

Repository: dashpay/dash-wallet

Length of output: 110


🏁 Script executed:

rg "CancellationException" --type kt | head -20

Repository: dashpay/dash-wallet

Length of output: 90


🏁 Script executed:

rg "CancellationException" --type kotlin | head -20

Repository: dashpay/dash-wallet

Length of output: 1572


🏁 Script executed:

rg "CancellationException" | head -20

Repository: dashpay/dash-wallet

Length of output: 1572


🏁 Script executed:

cat wallet/src/de/schildbach/wallet/service/platform/PlatformSyncService.kt | grep -A 5 "catch (_: CancellationException)" | head -20

Repository: dashpay/dash-wallet

Length of output: 541


🏁 Script executed:

cat .editorconfig | head -50

Repository: dashpay/dash-wallet

Length of output: 569


Rethrow coroutine cancellation before generic failure handling.

The catch (e: Exception) block catches coroutine cancellation, silencing it and returning null instead of propagating the cancellation signal. This breaks structured concurrency semantics in a suspend function. Add an explicit CancellationException handler before the generic handler, following the pattern used elsewhere in the codebase.

Proposed fix
+import kotlinx.coroutines.CancellationException
+
 ...
-        } catch (e: Exception) {
+        } catch (e: CancellationException) {
+            throw e
+        } catch (e: Exception) {
             log.error("Failed to refresh token: ${e.message}", e)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (e: Exception) {
log.error("Failed to refresh token: ${e.message}", e)
// Only a genuine credential rejection drops the cached token. The login endpoint
// returns HTTP 401 for that case, surfaced by ErrorHandlingInterceptor as a
// CTXSpendException; any other failure is transient, so the token is kept and the
// next request retries instead of forcing the user to sign in again.
if (e is CTXSpendException && e.errorCode == 401) {
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_ACCESS_TOKEN, "")
}
null
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log.error("Failed to refresh token: ${e.message}", e)
// Only a genuine credential rejection drops the cached token. The login endpoint
// returns HTTP 401 for that case, surfaced by ErrorHandlingInterceptor as a
// CTXSpendException; any other failure is transient, so the token is kept and the
// next request retries instead of forcing the user to sign in again.
if (e is CTXSpendException && e.errorCode == 401) {
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_ACCESS_TOKEN, "")
}
null
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/exploredash/src/main/java/org/dash/wallet/features/exploredash/network/authenticator/PiggyCardsAuthenticator.kt`
around lines 84 - 94, The generic catch block catching Exception in the token
refresh logic is intercepting CancellationException, which breaks structured
concurrency by silencing coroutine cancellation. Add an explicit catch handler
for CancellationException before the existing generic Exception catch block in
this try-catch structure, and rethrow the CancellationException immediately to
preserve proper coroutine cancellation semantics.

}

private suspend fun handleLoginResponse(
response: LoginResponse
) {
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_ACCESS_TOKEN, response.accessToken)
private suspend fun persistLogin(accessToken: String, expiresIn: Int) {
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_ACCESS_TOKEN, accessToken)

val expiresAt = LocalDateTime.now().plusSeconds(response.expiresIn.toLong())
val expiresAt = LocalDateTime.now().plusSeconds(expiresIn.toLong())
config.setSecuredData(
PiggyCardsConfig.PREFS_KEY_TOKEN_EXPIRES_AT,
expiresAt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
)
}

private val Response.responseCount: Int
get() {
var result = 1
var priorResponse = this.priorResponse
while (priorResponse != null) {
result++
priorResponse = priorResponse.priorResponse
}
return result
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import org.dash.wallet.common.WalletDataProvider
import org.dash.wallet.features.exploredash.data.dashspend.GiftCardProviderType
import org.dash.wallet.features.exploredash.network.PiggyCardsRemoteDataSource
import org.dash.wallet.features.exploredash.network.RemoteDataSource
import org.dash.wallet.features.exploredash.network.authenticator.PiggyCardsAuthenticator
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
Expand Down Expand Up @@ -55,7 +56,9 @@ class DashSpendRepositoryFactory @Inject constructor(
private fun createPiggyCardsRepository(): PiggyCardsRepository {
val remoteDataSource = PiggyCardsRemoteDataSource(piggyCardsConfig, walletDataProvider)
val api = remoteDataSource.buildApi(PiggyCardsApi::class.java)
val tokenApi = remoteDataSource.buildTokenApi()
val authenticator = PiggyCardsAuthenticator(tokenApi, piggyCardsConfig)

return PiggyCardsRepository(api, piggyCardsConfig)
return PiggyCardsRepository(api, piggyCardsConfig, authenticator)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,14 @@ import org.dash.wallet.features.exploredash.data.dashspend.model.UpdatedMerchant
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.Brand
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.ExchangeRateResult
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.Giftcard
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.LoginRequest
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.LoginResponse
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.Order
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.OrderRequest
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.OrderResponse
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.SignupRequest
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.User
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.UserMetadata
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.VerifyOtpRequest
import org.dash.wallet.features.exploredash.network.authenticator.PiggyCardsAuthenticator
import org.dash.wallet.features.exploredash.network.service.piggycards.PiggyCardsApi
import org.dash.wallet.features.exploredash.ui.dashspend.GiftCardShoppingCart
import org.dash.wallet.features.exploredash.utils.PiggyCardsConfig
Expand All @@ -52,7 +51,8 @@ import kotlin.math.max

class PiggyCardsRepository @Inject constructor(
private val api: PiggyCardsApi,
private val config: PiggyCardsConfig
private val config: PiggyCardsConfig,
private val authenticator: PiggyCardsAuthenticator
) : DashSpendRepository {
companion object {
const val DEFAULT_COUNTRY = "US"
Expand Down Expand Up @@ -101,32 +101,10 @@ class PiggyCardsRepository @Inject constructor(
}

private suspend fun performAutoLogin(): Boolean {
return try {
val userId = config.getSecuredData(PiggyCardsConfig.PREFS_KEY_USER_ID)
val password = config.getSecuredData(PiggyCardsConfig.PREFS_KEY_PASSWORD)

if (userId != null && password != null) {
val response = api.login(LoginRequest(userId = userId, password = password))
handleLoginResponse(response)
} else {
false
}
} catch (e: Exception) {
log.error("Failed to perform auto login: ${e.message}", e)
false
}
}

private suspend fun handleLoginResponse(response: LoginResponse): Boolean {
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_ACCESS_TOKEN, response.accessToken)

val expiresAt = LocalDateTime.now().plusSeconds(response.expiresIn.toLong())
config.setSecuredData(
PiggyCardsConfig.PREFS_KEY_TOKEN_EXPIRES_AT,
expiresAt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
)

return response.accessToken.isNotEmpty()
// Delegate to the authenticator so this shares the same process-wide lock and token
// persistence as the OkHttp retry path, avoiding overlapping logins that could leave
// the cached token in an inconsistent state.
return authenticator.reLogin() != null
}

override suspend fun isUserSignedIn(): Boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* Copyright (c) 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 <http://www.gnu.org/licenses/>.
*/

package org.dash.wallet.features.exploredash

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.runBlocking
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import org.dash.wallet.common.WalletDataProvider
import org.dash.wallet.common.util.security.EncryptionProvider
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.LoginRequest
import org.dash.wallet.features.exploredash.data.dashspend.piggycards.model.LoginResponse
import org.dash.wallet.features.exploredash.network.authenticator.PiggyCardsAuthenticator
import org.dash.wallet.features.exploredash.network.service.piggycards.PiggyCardsTokenApi
import org.dash.wallet.features.exploredash.repository.CTXSpendException
import org.dash.wallet.features.exploredash.utils.PiggyCardsConfig
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.robolectric.RobolectricTestRunner
import java.io.IOException
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.thread
import kotlin.math.max

/**
* Verifies the PiggyCards auth hardening that mirrors PR #1492's CTX fixes:
* - re-logins are serialized by a process-wide (companion) lock across instances, and
* - a transient failure keeps the cached token while a genuine 401 clears it.
*/
@RunWith(RobolectricTestRunner::class)
class PiggyCardsAuthenticatorTest {

private val identityEncryption = object : EncryptionProvider {
override fun encrypt(keyAlias: String, textToEncrypt: String): ByteArray = textToEncrypt.toByteArray()
override fun decrypt(keyAlias: String, encryptedData: ByteArray): String = String(encryptedData)
override fun deleteKey(keyAlias: String) {}
}

private fun realConfig(): PiggyCardsConfig {
val context = ApplicationProvider.getApplicationContext<Context>()
return PiggyCardsConfig(context, mock<WalletDataProvider>(), identityEncryption)
}

private fun unauthorizedResponse(): Response =
Response.Builder()
.request(Request.Builder().url("https://example.com/").build())
.protocol(Protocol.HTTP_1_1)
.code(401)
.message("Unauthorized")
.build()

@Test
fun reLogin_acrossInstances_isSerialized_byProcessWideLock() {
val config = realConfig()
runBlocking {
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_USER_ID, "user-1")
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_PASSWORD, "pw-1")
}

val concurrent = AtomicInteger(0)
val maxConcurrent = AtomicInteger(0)

val tokenApi = object : PiggyCardsTokenApi {
override suspend fun login(loginRequest: LoginRequest): LoginResponse {
val now = concurrent.incrementAndGet()
maxConcurrent.updateAndGet { max(it, now) }
Thread.sleep(200)
concurrent.decrementAndGet()
return LoginResponse(accessToken = "fresh-token", tokenType = "Bearer", expiresIn = 3600)
}
}
val authenticatorA = PiggyCardsAuthenticator(tokenApi, config)
val authenticatorB = PiggyCardsAuthenticator(tokenApi, config)
val response = unauthorizedResponse()

val threadA = thread { authenticatorA.authenticate(null, response) }
val threadB = thread { authenticatorB.authenticate(null, response) }
threadA.join()
threadB.join()
Comment on lines +95 to +98

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Propagate failures from the worker threads.

Exceptions thrown inside thread { ... } do not fail this JUnit test; capture and rethrow them after join() so one broken authenticate() path cannot be hidden.

Proposed fix
 import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicReference
 ...
         val response = unauthorizedResponse()
+        val failure = AtomicReference<Throwable?>()
 
-        val threadA = thread { authenticatorA.authenticate(null, response) }
-        val threadB = thread { authenticatorB.authenticate(null, response) }
+        val threadA = thread {
+            runCatching { authenticatorA.authenticate(null, response) }
+                .exceptionOrNull()
+                ?.let(failure::set)
+        }
+        val threadB = thread {
+            runCatching { authenticatorB.authenticate(null, response) }
+                .exceptionOrNull()
+                ?.let(failure::set)
+        }
         threadA.join()
         threadB.join()
+        failure.get()?.let { throw it }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val threadA = thread { authenticatorA.authenticate(null, response) }
val threadB = thread { authenticatorB.authenticate(null, response) }
threadA.join()
threadB.join()
val response = unauthorizedResponse()
val failure = AtomicReference<Throwable?>()
val threadA = thread {
runCatching { authenticatorA.authenticate(null, response) }
.exceptionOrNull()
?.let(failure::set)
}
val threadB = thread {
runCatching { authenticatorB.authenticate(null, response) }
.exceptionOrNull()
?.let(failure::set)
}
threadA.join()
threadB.join()
failure.get()?.let { throw it }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/exploredash/src/test/java/org/dash/wallet/features/exploredash/PiggyCardsAuthenticatorTest.kt`
around lines 95 - 98, The test creates threadA and threadB using thread {
authenticatorA.authenticate(...) } and thread { authenticatorB.authenticate(...)
} but any exceptions thrown within these threads are not captured or rethrown
after the join() calls, allowing failures to be silently ignored. Capture any
exceptions thrown by each thread (using try-catch or similar mechanism within
the thread creation), store them, and after both threadA.join() and
threadB.join() complete, rethrow any captured exceptions so that failures in
either authenticate() call will properly fail the test.


// The shared companion lock prevents overlapping re-logins across separate instances.
assertEquals(1, maxConcurrent.get())
}

@Test
fun reLogin_onTransientFailure_preservesToken() = runBlocking {
val config = realConfig()
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_USER_ID, "user-1")
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_PASSWORD, "pw-1")
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_ACCESS_TOKEN, "existing-token")

val tokenApi = object : PiggyCardsTokenApi {
override suspend fun login(loginRequest: LoginRequest): LoginResponse =
throw IOException("transient network failure") // not a 401
}
val authenticator = PiggyCardsAuthenticator(tokenApi, config)

val result = authenticator.reLogin()

assertNull(result)
// Transient failure must NOT wipe the cached token: the session survives and retries.
assertEquals("existing-token", config.getSecuredData(PiggyCardsConfig.PREFS_KEY_ACCESS_TOKEN))
}

@Test
fun reLogin_onGenuine401_clearsToken() = runBlocking {
val config = realConfig()
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_USER_ID, "user-1")
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_PASSWORD, "pw-1")
config.setSecuredData(PiggyCardsConfig.PREFS_KEY_ACCESS_TOKEN, "existing-token")

val tokenApi = object : PiggyCardsTokenApi {
override suspend fun login(loginRequest: LoginRequest): LoginResponse =
throw CTXSpendException("unauthorized", errorCode = 401) // genuine credential rejection
}
val authenticator = PiggyCardsAuthenticator(tokenApi, config)

val result = authenticator.reLogin()

assertNull(result)
// A genuine 401 means the credentials are no longer valid, so the cached token is cleared.
assertEquals("", config.getSecuredData(PiggyCardsConfig.PREFS_KEY_ACCESS_TOKEN))
}
}
Loading