From 68ef3a94d7cdb1147c1db9c25a778af9051e7a81 Mon Sep 17 00:00:00 2001 From: Your Name <1336281+CDRussell@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:05:23 +0100 Subject: [PATCH] Replace thread.sleep in sync exchange v2 with coroutine-based delay --- .../sync/impl/RealSyncCodeDispatcher.kt | 6 ++-- .../sync/impl/SyncAccountRepository.kt | 13 +++---- .../sync/impl/ThirdPartyCredentialManager.kt | 19 +++++----- .../sync/impl/exchange/v2/ExchangeV2Runner.kt | 7 ++-- .../impl/exchange/v2/RecoveryCodeProvider.kt | 4 +-- .../sync/impl/AppSyncAccountRepositoryTest.kt | 35 ++++++++++--------- .../sync/impl/RealSyncCodeDispatcherTest.kt | 2 +- .../impl/ThirdPartyCredentialManagerTest.kt | 31 ++++++++-------- .../v2/RealRecoveryCodeProviderTest.kt | 7 ++-- 9 files changed, 65 insertions(+), 59 deletions(-) diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/RealSyncCodeDispatcher.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/RealSyncCodeDispatcher.kt index 137905740d4d..80755120eaf6 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/RealSyncCodeDispatcher.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/RealSyncCodeDispatcher.kt @@ -125,7 +125,7 @@ class RealSyncCodeDispatcher @Inject constructor( * or null for intermediate events the caller should ignore. Both Host and Joiner roles are * reachable here; mirrors [mapV2LinkingEventToOutcome] (login on Joiner.Done, preserve abort reason). */ - private fun mapV2PresentEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) { + private suspend fun mapV2PresentEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) { is ExchangeV2Event.SessionStarted -> event.linkingCode?.let { DispatchOutcome.LinkingCodeReady(it) } is ExchangeV2Event.Transition -> when (event.to) { ExchangeV2State.Joiner.Confirming -> @@ -321,7 +321,7 @@ class RealSyncCodeDispatcher @Inject constructor( * Translate one runner event into a terminal [DispatchOutcome] for the v2 scanner flow, * or null for intermediate events the caller should ignore. */ - private fun mapV2LinkingEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) { + private suspend fun mapV2LinkingEventToOutcome(event: ExchangeV2Event, peerKind: PeerKind?): DispatchOutcome? = when (event) { is ExchangeV2Event.Transition -> when (event.to) { ExchangeV2State.Joiner.Confirming -> DispatchOutcome.JoinerConfirmationRequested(peerName = runner.peerName, peerKind = peerKind) @@ -376,7 +376,7 @@ class RealSyncCodeDispatcher @Inject constructor( * cid=ddg logs in directly; cid=3party upgrades the account via * [SyncAccountRepository.joinAccountFromThirdPartyRecoveryCode]. */ - private fun loginWithV2RecoveryCode(b64: String, peerKind: PeerKind?): DispatchOutcome { + private suspend fun loginWithV2RecoveryCode(b64: String, peerKind: PeerKind?): DispatchOutcome { val parsed = decodeV2Recovery(b64) ?: return DispatchOutcome.Failed("Couldn't parse received recovery code as v2.0") return when (parsed.cid) { CID_DDG -> { diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt index 8752136553b6..d6466746bdc1 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt @@ -53,6 +53,7 @@ import com.squareup.anvil.annotations.* import com.squareup.moshi.* import dagger.* import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import logcat.LogPriority.ERROR @@ -96,7 +97,7 @@ interface SyncAccountRepository { * adopted locally; otherwise a new one is created on the server. Either path leaves the * local store ready for [getThirdPartyRecoveryCode]. */ - fun createThirdPartyCredential(): Result + suspend fun createThirdPartyCredential(): Result /** * Fetches the 3party credential from the server, decrypts the SP using the account's secretKey, @@ -127,7 +128,7 @@ interface SyncAccountRepository { * populated, credentialId=ddg, scopedPassword populated with SP. SyncStore is written only * after all three network calls succeed; observers never see an intermediate state. */ - fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result + suspend fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result /** * Returns a recovery code that a 3rd-party browser can use to sign in and access this @@ -625,7 +626,7 @@ class AppSyncAccountRepository @Inject constructor( } } - override fun createThirdPartyCredential(): Result = thirdPartyCredentialManager.create() + override suspend fun createThirdPartyCredential(): Result = thirdPartyCredentialManager.create() override fun refreshThirdPartyCredential(): Result = thirdPartyCredentialManager.refresh() @@ -758,7 +759,7 @@ class AppSyncAccountRepository @Inject constructor( ) } - override fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result { + override suspend fun joinAccountFromThirdPartyRecoveryCode(pastedCode: String): Result { if (!syncFeature.canUseV2ConnectFlow().isEnabled()) { return Error(reason = "JoinFrom3party: canUseV2ConnectFlow is disabled") } @@ -1338,7 +1339,7 @@ class AppSyncAccountRepository @Inject constructor( return this } - private fun retryingOnTransientError(block: () -> Result): Result { + private suspend fun retryingOnTransientError(block: () -> Result): Result { var attempt = 0 while (true) { val result = block() @@ -1346,7 +1347,7 @@ class AppSyncAccountRepository @Inject constructor( if (code != null && isRetryableTransient(code) && attempt < MAX_UPGRADE_RETRIES) { attempt++ logcat { "Sync-ScopedToken: upgrade call transient error (code=$code); retry $attempt/$MAX_UPGRADE_RETRIES" } - runCatching { Thread.sleep(upgradeRetryDelayMillis * attempt) } + delay(upgradeRetryDelayMillis * attempt) continue } return result diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ThirdPartyCredentialManager.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ThirdPartyCredentialManager.kt index 9339ee945e05..93cdb75044f4 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ThirdPartyCredentialManager.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/ThirdPartyCredentialManager.kt @@ -30,6 +30,7 @@ import com.duckduckgo.sync.store.SyncStore import com.squareup.anvil.annotations.ContributesBinding import com.squareup.moshi.Moshi import dagger.SingleInstanceIn +import kotlinx.coroutines.delay import logcat.LogPriority.ERROR import logcat.logcat import javax.inject.Inject @@ -46,7 +47,7 @@ interface ThirdPartyCredentialManager { * already created it, the existing credential is adopted locally; otherwise a new one is created * on the server. Either path leaves [SyncStore.scopedPassword] populated. */ - fun create(): Result + suspend fun create(): Result /** * Fetches the 3party credential from the server, decrypts its SP using the account's primary @@ -78,7 +79,7 @@ class RealThirdPartyCredentialManager @Inject constructor( @VisibleForTesting internal var rateLimitRetryDelayMillis: Long = RATE_LIMIT_RETRY_DELAY_MILLIS - override fun create(): Result { + override suspend fun create(): Result { val inputs = when (val r = validateCreatePreconditions()) { is Success -> r.data is Error -> return r @@ -150,7 +151,7 @@ class RealThirdPartyCredentialManager @Inject constructor( return AdoptResult.Adopted } - private fun mintAndPostNewCredential(inputs: CreateInputs): Result { + private suspend fun mintAndPostNewCredential(inputs: CreateInputs): Result { logcat { "Sync-ScopedToken: generating new 3party credential" } // Re-auth against the existing ddg credential's twice_hashed_password. @@ -214,7 +215,7 @@ class RealThirdPartyCredentialManager @Inject constructor( * the credential POST means the server writes them atomically, removing the need for a * separate `POST /sync/keys/.../set-if-absent` call (and the race window that opened). */ - private fun buildKeysForNewThirdPartyCredential( + private suspend fun buildKeysForNewThirdPartyCredential( token: String, newSpBase64: String, hkdfSalt: ByteArray, @@ -317,7 +318,7 @@ class RealThirdPartyCredentialManager @Inject constructor( ) } - private fun postCreateAccessCredential( + private suspend fun postCreateAccessCredential( inputs: CreateInputs, request: CreateAccessCredentialRequest, newSpBase64: String, @@ -359,7 +360,7 @@ class RealThirdPartyCredentialManager @Inject constructor( * Other 409 variants (e.g. `credential_already_exists`) are NOT retried — they're returned * to the caller, which handles them via the adopt path. */ - private fun postWithConcurrentModificationRetry( + private suspend fun postWithConcurrentModificationRetry( token: String, request: CreateAccessCredentialRequest, ): Result { @@ -372,7 +373,7 @@ class RealThirdPartyCredentialManager @Inject constructor( if (isConcurrentMod && attempt < MAX_CONCURRENT_MODIFICATION_RETRIES) { attempt++ logcat { "Sync-ScopedToken: 3party credential POST hit concurrent_modification; retry $attempt/$MAX_CONCURRENT_MODIFICATION_RETRIES" } - runCatching { Thread.sleep(CONCURRENT_MODIFICATION_RETRY_DELAY_MILLIS * attempt) } + delay(CONCURRENT_MODIFICATION_RETRY_DELAY_MILLIS * attempt) continue } return result @@ -476,7 +477,7 @@ class RealThirdPartyCredentialManager @Inject constructor( * Retry an idempotent scoped-credential call on rate-limit. * A bounded linear backoff recovers since the limit is per-window and the call is idempotent. */ - private fun retryingOnRateLimit(block: () -> Result): Result { + private suspend fun retryingOnRateLimit(block: () -> Result): Result { var attempt = 0 while (true) { val result = block() @@ -485,7 +486,7 @@ class RealThirdPartyCredentialManager @Inject constructor( if (rateLimited && attempt < MAX_RATE_LIMIT_RETRIES) { attempt++ logcat { "Sync-ScopedToken: rate-limited (code=$code); retry $attempt/$MAX_RATE_LIMIT_RETRIES" } - runCatching { Thread.sleep(rateLimitRetryDelayMillis * attempt) } + delay(rateLimitRetryDelayMillis * attempt) continue } return result diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/exchange/v2/ExchangeV2Runner.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/exchange/v2/ExchangeV2Runner.kt index 1f95c848fb59..b67082be357d 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/exchange/v2/ExchangeV2Runner.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/exchange/v2/ExchangeV2Runner.kt @@ -433,8 +433,9 @@ class RealExchangeV2Runner @Inject constructor( * (or `recovery_code_unavailable` if it can't be produced), then advance the SM. */ private fun shareRecoveryCodeAndAdvanceLocked() { - val responseOk = sendRecoveryCodeResponse() appScope.launch(dispatchers.io()) { + if (session == null) return@launch + val responseOk = sendRecoveryCodeResponse() mutex.withLock { val s = session ?: return@withLock if (s.currentState != ExchangeV2State.Host.Sending) return@withLock @@ -657,7 +658,7 @@ class RealExchangeV2Runner @Inject constructor( * `recovery_code_unavailable` to the peer + emitted a SessionError event, and the SM * should be driven to [ExchangeV2State.Host.Aborted] rather than Done). */ - private fun sendRecoveryCodeResponse(): Boolean { + private suspend fun sendRecoveryCodeResponse(): Boolean { val peer = peerChannelId ?: return false val peerKey = peerPublicKey ?: return false // Recovery code per peer kind. Per spec §"Exchange Share Recovery Code": create the host @@ -703,7 +704,7 @@ class RealExchangeV2Runner @Inject constructor( } } - private fun provisionForThirdPartyPeer(): Result { + private suspend fun provisionForThirdPartyPeer(): Result { when (val ddg = recoveryCodeProvider.createDdgAccountIfNeeded()) { is Result.Success -> Unit is Result.Error -> { diff --git a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/exchange/v2/RecoveryCodeProvider.kt b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/exchange/v2/RecoveryCodeProvider.kt index 6f37de7b1172..54d5b23ac2d2 100644 --- a/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/exchange/v2/RecoveryCodeProvider.kt +++ b/sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/exchange/v2/RecoveryCodeProvider.kt @@ -56,7 +56,7 @@ interface RecoveryCodeProvider { * 3party, if needed, extend the account."* No-op if a 3party credential already exists locally * ([SyncStore.scopedPassword] set); otherwise creates one. Failure surfaces as [Result.Error]. */ - fun createThirdPartyCredentialIfNeeded(): Result + suspend fun createThirdPartyCredentialIfNeeded(): Result } @ContributesBinding(AppScope::class) @@ -73,7 +73,7 @@ class RealRecoveryCodeProvider @Inject constructor( } } - override fun createThirdPartyCredentialIfNeeded(): Result { + override suspend fun createThirdPartyCredentialIfNeeded(): Result { if (syncStore.scopedPassword != null) return Result.Success(Unit) return when (val r = syncAccountRepository.createThirdPartyCredential()) { is Result.Success -> Result.Success(Unit) diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt index 3327536409c0..fa6541701a2e 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt @@ -84,6 +84,7 @@ import com.duckduckgo.sync.store.ScopedPassword import com.duckduckgo.sync.store.SyncStore import com.squareup.moshi.Moshi import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -1245,7 +1246,7 @@ class AppSyncAccountRepositoryTest { // Per-manager logic is covered in ThirdPartyCredentialManagerTest. @Test - fun whenCreateThirdPartyCredentialThenDelegatesToManager() { + fun whenCreateThirdPartyCredentialThenDelegatesToManager() = runTest { whenever(thirdPartyCredentialManager.create()).thenReturn(Success(true)) val result = syncRepo.createThirdPartyCredential() @@ -1288,7 +1289,7 @@ class AppSyncAccountRepositoryTest { // joinAccountFromThirdPartyRecoveryCode @Test - fun whenJoinAccountFromThirdPartyAndAllStepsSucceedThenAtomicStoreUsesDdgTokenFromSecondLogin() { + fun whenJoinAccountFromThirdPartyAndAllStepsSucceedThenAtomicStoreUsesDdgTokenFromSecondLogin() = runTest { val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode() val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode) @@ -1306,7 +1307,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenJoinAccountFromThirdPartySucceedsThenScopedPasswordIsWrittenFromLocalRecoveryCode() { + fun whenJoinAccountFromThirdPartySucceedsThenScopedPasswordIsWrittenFromLocalRecoveryCode() = runTest { // Defensive: even if the BE response omits encrypted_3party_credential (so performLogin's // server-decoded SP path is a no-op), the join should still leave scopedPassword populated // from the locally-parsed recovery code. @@ -1320,7 +1321,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenJoinAccountFromThirdPartyThenStep7aPostUpgradeLoginUsesUnrestrictedScope() { + fun whenJoinAccountFromThirdPartyThenStep7aPostUpgradeLoginUsesUnrestrictedScope() = runTest { val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode() syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode) @@ -1345,7 +1346,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenStep7aPostUpgradeLoginReturns401ThenAbortsWithoutAtomicStore() { + fun whenStep7aPostUpgradeLoginReturns401ThenAbortsWithoutAtomicStore() = runTest { val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(step7aResult = step7aLoginUnauthorized) val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode) @@ -1356,7 +1357,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenStep7aPostUpgradeLoginFailsWithNon401ThenAbortsWithoutAtomicStore() { + fun whenStep7aPostUpgradeLoginFailsWithNon401ThenAbortsWithoutAtomicStore() = runTest { val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(step7aResult = step7aLoginServerError) val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode) @@ -1370,7 +1371,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenJoinAccountFromThirdPartyAndAccountAlreadyHasDdgCredentialThenAbortsBeforePost() { + fun whenJoinAccountFromThirdPartyAndAccountAlreadyHasDdgCredentialThenAbortsBeforePost() = runTest { val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(accountAlreadyHasDdg = true) val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode) @@ -1383,7 +1384,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenStep7PostRateLimitedThenRetriesAndSucceeds() { + fun whenStep7PostRateLimitedThenRetriesAndSucceeds() = runTest { val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode() whenever(syncApi.createAccessCredential(anyString(), eq("ddg"), any())) .thenReturn(Error(code = API_CODE.TOO_MANY_REQUESTS_2.code, reason = "rate limited"), Success(true)) @@ -1395,7 +1396,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenStep7aLoginServerErrorThenRetriesAndSucceeds() { + fun whenStep7aLoginServerErrorThenRetriesAndSucceeds() = runTest { val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode() whenever(syncApi.login(eq(userId), any(), eq(deviceId), any(), any(), eq(null))) .thenReturn(step7aLoginServerError, step7aLoginSuccess) @@ -1415,7 +1416,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenStep7aLoginTransportErrorThenRetriesAndSucceeds() { + fun whenStep7aLoginTransportErrorThenRetriesAndSucceeds() = runTest { val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode() whenever(syncApi.login(eq(userId), any(), eq(deviceId), any(), any(), eq(null))) .thenReturn(step7aLoginTransportError, step7aLoginSuccess) @@ -1427,7 +1428,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenStep7aLogin401ThenAbortsWithoutRetry() { + fun whenStep7aLogin401ThenAbortsWithoutRetry() = runTest { val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(step7aResult = step7aLoginUnauthorized) val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode) @@ -1438,7 +1439,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenStep7Post409AlreadyExistsThenAbortsWithoutRetry() { + fun whenStep7Post409AlreadyExistsThenAbortsWithoutRetry() = runTest { val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode() whenever(syncApi.createAccessCredential(anyString(), eq("ddg"), any())) .thenReturn(Error(code = API_CODE.COUNT_LIMIT.code, reason = "already exists")) @@ -1451,7 +1452,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenStep7PostRateLimitedBeyondRetryLimitThenAbortsWithoutStore() { + fun whenStep7PostRateLimitedBeyondRetryLimitThenAbortsWithoutStore() = runTest { val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode() whenever(syncApi.createAccessCredential(anyString(), eq("ddg"), any())) .thenReturn(Error(code = API_CODE.TOO_MANY_REQUESTS_1.code, reason = "rate limited")) @@ -1465,7 +1466,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenJoinAccountFromThirdPartyAndCodeIsLaterMinorVersionThenAccepted() { + fun whenJoinAccountFromThirdPartyAndCodeIsLaterMinorVersionThenAccepted() = runTest { val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(version = "2.5") val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode) @@ -1474,7 +1475,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenJoinAccountFromThirdPartyAndCodeIsBareMajorVersionThenAccepted() { + fun whenJoinAccountFromThirdPartyAndCodeIsBareMajorVersionThenAccepted() = runTest { // "2" is the spec's common shorthand for "2.0" (Transport TD 1214486492252757). val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(version = "2") @@ -1484,7 +1485,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenJoinAccountFromThirdPartyAndCodeIsMajorVersion3ThenRejectedWithoutNetwork() { + fun whenJoinAccountFromThirdPartyAndCodeIsMajorVersion3ThenRejectedWithoutNetwork() = runTest { val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(version = "3.0") val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode) @@ -1494,7 +1495,7 @@ class AppSyncAccountRepositoryTest { } @Test - fun whenJoinAccountFromThirdPartyAndCodeCidIsNotThirdPartyThenRejectedWithoutNetwork() { + fun whenJoinAccountFromThirdPartyAndCodeCidIsNotThirdPartyThenRejectedWithoutNetwork() = runTest { val pastedCode = prepareForJoinAccountFromThirdPartyRecoveryCode(cid = "ddg") val result = syncRepo.joinAccountFromThirdPartyRecoveryCode(pastedCode) diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/RealSyncCodeDispatcherTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/RealSyncCodeDispatcherTest.kt index 33e8420a3c10..8a962cfa2264 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/RealSyncCodeDispatcherTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/RealSyncCodeDispatcherTest.kt @@ -448,7 +448,7 @@ class RealSyncCodeDispatcherTest { assertTrue((outcome as DispatchOutcome.Failed).reason.contains("future-credential")) } - @Test fun `Legacy path - dispatcher never invokes processCode (caller owns that)`() { + @Test fun `Legacy path - dispatcher never invokes processCode (caller owns that)`() = runTest { setV2(false) whenever(syncAccountRepository.parseSyncAuthCode(any())).thenReturn(SyncAuthCode.Recovery(mock())) diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ThirdPartyCredentialManagerTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ThirdPartyCredentialManagerTest.kt index 0103f2291b7a..f61661e2593f 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ThirdPartyCredentialManagerTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/ThirdPartyCredentialManagerTest.kt @@ -36,6 +36,7 @@ import com.duckduckgo.sync.impl.crypto.SyncJweCrypto import com.duckduckgo.sync.store.ScopedPassword import com.duckduckgo.sync.store.SyncStore import com.squareup.moshi.Moshi +import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -82,13 +83,13 @@ class ThirdPartyCredentialManagerTest { // ---- create() ---- @Test - fun whenFlagOffThenCreateReturnsError() { + fun whenFlagOffThenCreateReturnsError() = runTest { val result = manager.create() assertTrue(result is Error) } @Test - fun whenNotSignedInThenCreateReturnsError() { + fun whenNotSignedInThenCreateReturnsError() = runTest { syncFeature.canUseV2ConnectFlow().setRawStoredState(State(true)) whenever(syncStore.token).thenReturn(null) @@ -97,7 +98,7 @@ class ThirdPartyCredentialManagerTest { } @Test - fun whenCredentialAlreadyExistsOnServerThenDecryptsAndStores() { + fun whenCredentialAlreadyExistsOnServerThenDecryptsAndStores() = runTest { // Server's `encryptedCredential` is decrypted via AES-GCM-JWE-dir(SP, DDG-MEK); the // resulting base64url SP is re-encoded as standard base64 for local storage. Fixture // chosen so wire ("_-__") and stored ("/+//") differ on every character. @@ -121,7 +122,7 @@ class ThirdPartyCredentialManagerTest { } @Test - fun whenServerReturnsCredentialMissingEncryptedThenError() { + fun whenServerReturnsCredentialMissingEncryptedThenError() = runTest { syncFeature.canUseV2ConnectFlow().setRawStoredState(State(true)) whenever(syncStore.token).thenReturn(token) whenever(syncApi.getAccessCredentials(token)).thenReturn( @@ -135,7 +136,7 @@ class ThirdPartyCredentialManagerTest { } @Test - fun whenCreateSucceedsThenStoresScopedPassword() { + fun whenCreateSucceedsThenStoresScopedPassword() = runTest { primeCreate() whenever(syncApi.getProtectedKeys(token)).thenReturn(Success(listOf(existingDdgKey))) @@ -146,7 +147,7 @@ class ThirdPartyCredentialManagerTest { } @Test - fun whenCreateSucceedsThenCredentialHashedPasswordIsHkdfOfSpRawBytes() { + fun whenCreateSucceedsThenCredentialHashedPasswordIsHkdfOfSpRawBytes() = runTest { // Regression guard: 3party `credential_hashed_password` MUST be HKDF-SHA-256(SP_raw, // salt=user_id_bytes, info="Password", 32) re-encoded as base64url (no padding). Per // Encryption Algorithms TD §"Hashed password derivation" (Asana 1214802412121967). @@ -183,7 +184,7 @@ class ThirdPartyCredentialManagerTest { } @Test - fun whenExistingKeysOnServerThenReEncryptsForThirdParty() { + fun whenExistingKeysOnServerThenReEncryptsForThirdParty() = runTest { primeCreate() val existingKey = ProtectedKeyEntry( kid = "k1", @@ -212,7 +213,7 @@ class ThirdPartyCredentialManagerTest { } @Test - fun whenCreateAccessCredentialApiFailsThenError() { + fun whenCreateAccessCredentialApiFailsThenError() = runTest { primeCreate() whenever(syncApi.getProtectedKeys(token)).thenReturn(Success(listOf(existingDdgKey))) whenever(syncApi.createAccessCredential(anyString(), anyString(), any())).thenReturn( @@ -225,7 +226,7 @@ class ThirdPartyCredentialManagerTest { } @Test - fun whenNoDdgProtectedKeysThenLocallyMintsAiChatsAndIncludesBothWrappingsInCredential() { + fun whenNoDdgProtectedKeysThenLocallyMintsAiChatsAndIncludesBothWrappingsInCredential() = runTest { // BUG-C: a freshly-created ddg account has no protected keys. Per Unified Algorithm // §"Obtaining 3party secret": "if there are no Protected Keys, generate new ones for // ai_chat purpose". Otherwise POST /access-credentials/3party carries no keys and the real @@ -262,7 +263,7 @@ class ThirdPartyCredentialManagerTest { } @Test - fun whenGetKeysRateLimitedThenRetriesAndSucceeds() { + fun whenGetKeysRateLimitedThenRetriesAndSucceeds() = runTest { // BUG-B: the no-account Host provisioning burst gets HTTP 418 (TOO_MANY_REQUESTS_2) on // GET /keys. The call is idempotent, so a bounded retry-on-rate-limit should recover. (manager as RealThirdPartyCredentialManager).rateLimitRetryDelayMillis = 0L @@ -279,7 +280,7 @@ class ThirdPartyCredentialManagerTest { } @Test - fun whenGetKeysRateLimitedBeyondRetryLimitThenError() { + fun whenGetKeysRateLimitedBeyondRetryLimitThenError() = runTest { (manager as RealThirdPartyCredentialManager).rateLimitRetryDelayMillis = 0L primeCreate() whenever(syncApi.getProtectedKeys(token)).thenReturn(Error(code = API_CODE.TOO_MANY_REQUESTS_2.code)) @@ -292,7 +293,7 @@ class ThirdPartyCredentialManagerTest { } @Test - fun whenCreate409ConflictAndCredentialNowPresentThenAdoptsAndStoresSp() { + fun whenCreate409ConflictAndCredentialNowPresentThenAdoptsAndStoresSp() = runTest { // Spec (Asana 1214702966683640, "Setting up usage of a new scope"): on conflict, // refetch and adopt the credential another device just created. primeCreate() @@ -317,7 +318,7 @@ class ThirdPartyCredentialManagerTest { } @Test - fun whenCreate409ConflictButCredentialStillMissingThenError() { + fun whenCreate409ConflictButCredentialStillMissingThenError() = runTest { primeCreate() whenever(syncApi.getProtectedKeys(token)).thenReturn(Success(listOf(existingDdgKey))) whenever(syncApi.createAccessCredential(anyString(), anyString(), any())).thenReturn( @@ -331,7 +332,7 @@ class ThirdPartyCredentialManagerTest { } @Test - fun whenGetProtectedKeysFailsThenAbortsBeforeCreatingCredential() { + fun whenGetProtectedKeysFailsThenAbortsBeforeCreatingCredential() = runTest { primeCreate() whenever(syncApi.getProtectedKeys(token)).thenReturn(Error(code = 500, reason = "getKeys failed")) @@ -490,7 +491,7 @@ class ThirdPartyCredentialManagerTest { } @Test - fun whenCreateAccessCredentialPostedThenRequestJsonMatchesSpec() { + fun whenCreateAccessCredentialPostedThenRequestJsonMatchesSpec() = runTest { primeCreate() whenever(syncApi.getProtectedKeys(token)).thenReturn(Success(listOf(existingDdgKey))) diff --git a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/exchange/v2/RealRecoveryCodeProviderTest.kt b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/exchange/v2/RealRecoveryCodeProviderTest.kt index bfddc53f35eb..3904467a1082 100644 --- a/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/exchange/v2/RealRecoveryCodeProviderTest.kt +++ b/sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/exchange/v2/RealRecoveryCodeProviderTest.kt @@ -21,6 +21,7 @@ import com.duckduckgo.sync.impl.Result import com.duckduckgo.sync.impl.SyncAccountRepository import com.duckduckgo.sync.store.ScopedPassword import com.duckduckgo.sync.store.SyncStore +import kotlinx.coroutines.test.runTest import org.json.JSONObject import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals @@ -162,7 +163,7 @@ class RealRecoveryCodeProviderTest { // ---- createThirdPartyCredentialIfNeeded ---- - @Test fun `createThirdPartyCredentialIfNeeded is a no-op when scopedPassword already set locally`() { + @Test fun `createThirdPartyCredentialIfNeeded is a no-op when scopedPassword already set locally`() = runTest { // ScopedPassword is a `value class` so Mockito can't mock it — use a real instance. whenever(syncStore.scopedPassword).thenReturn(ScopedPassword("existing")) @@ -172,7 +173,7 @@ class RealRecoveryCodeProviderTest { verify(syncAccountRepository, org.mockito.kotlin.never()).createThirdPartyCredential() } - @Test fun `createThirdPartyCredentialIfNeeded extends the account when no scopedPassword locally`() { + @Test fun `createThirdPartyCredentialIfNeeded extends the account when no scopedPassword locally`() = runTest { whenever(syncStore.scopedPassword).thenReturn(null) whenever(syncAccountRepository.createThirdPartyCredential()).thenReturn(Result.Success(true)) @@ -182,7 +183,7 @@ class RealRecoveryCodeProviderTest { verify(syncAccountRepository).createThirdPartyCredential() } - @Test fun `createThirdPartyCredentialIfNeeded propagates repository error`() { + @Test fun `createThirdPartyCredentialIfNeeded propagates repository error`() = runTest { whenever(syncStore.scopedPassword).thenReturn(null) whenever(syncAccountRepository.createThirdPartyCredential()) .thenReturn(Result.Error(reason = "extend failed"))