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
24 changes: 0 additions & 24 deletions app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1086,16 +1086,6 @@ class PubkyRepo @Inject constructor(
}
}

suspend fun deleteProfileWithSessionRetry(): Result<Unit> = withContext(ioDispatcher) {
val initialResult = deleteProfile()
if (initialResult.isSuccess) return@withContext initialResult

val refreshedSession = refreshSessionIfPossible().getOrDefault(false)
if (!refreshedSession) return@withContext initialResult

deleteProfile()
}

suspend fun deleteProfile(): Result<Unit> = runSuspendCatching {
withContext(ioDispatcher) {
requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
Expand Down Expand Up @@ -1812,20 +1802,6 @@ class PubkyRepo @Inject constructor(
}
}

suspend fun refreshSessionIfPossible(): Result<Boolean> = runSuspendCatching {
withContext(ioDispatcher) {
val storedSecretKeyHex = activeSecretKeyHex() ?: return@withContext false

pubkyService.signIn(storedSecretKeyHex)
val publicKey = pubkyService.publicKeyFromSecret(storedSecretKeyHex).ensurePubkyPrefix()

notifyBackupStateChanged()
continueSignIn(publicKey)

true
}
}

// endregion

// region Sign out
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ class EditProfileViewModel @Inject constructor(
try {
privatePaykitRepo.beginProfileDeletion()
privatePaykitRepo.removePublishedEndpointsForCleanup(TAG, deferAppSync = true)
val result = pubkyRepo.deleteProfileWithSessionRetry()
val result = pubkyRepo.deleteProfile()
if (result.isSuccess) {
privatePaykitRepo.closeAndClear()
ToastEventBus.send(
Expand Down
81 changes: 33 additions & 48 deletions app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1375,36 +1375,46 @@ class PubkyRepoTest : BaseUnitTest() {
}

@Test
fun `deleteProfileWithSessionRetry should refresh session and retry delete`() = test {
val expiredSession = "expired_session"
val newSession = "new_session"
fun `profile deletion does not replay after an uncertain response`() = test {
val secretKey = "local_secret"
authenticateForTesting(publicKey = VALID_SELF_KEY, secret = expiredSession)
whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(expiredSession, newSession)
authenticateForTesting(publicKey = VALID_SELF_KEY, secret = "test_session")
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey)
whenever(pubkyService.deletePaykitProfile()).thenAnswer { throw TestAppError("Expired") }.thenReturn(Unit)
whenever(pubkyService.signIn(secretKey)).thenReturn(Unit)
whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY.removePrefix("pubky"))
val uncertainResponse = TestAppError("Connection closed after profile deletion")
whenever(pubkyService.deletePaykitProfile()).thenAnswer { throw uncertainResponse }.thenReturn(Unit)

val result = sut.deleteProfileWithSessionRetry()
val result = sut.deleteProfile()

assertTrue(result.isSuccess)
verifyBlocking(pubkyService, times(2)) { deletePaykitProfile() }
assertSame(uncertainResponse, result.exceptionOrNull())
verify(pubkyService).deletePaykitProfile()
verify(pubkyService, never()).signIn(any())
verify(pubkyService, never()).signOut()
}

@Test
fun `deleteProfileWithSessionRetry should return failure when session cannot refresh`() = test {
val expiredSession = "expired_session"
authenticateForTesting(publicKey = VALID_SELF_KEY, secret = expiredSession)
whenever(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)).thenReturn(expiredSession)
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null)
whenever(pubkyService.deletePaykitProfile()).thenAnswer { throw TestAppError("Expired") }

val result = sut.deleteProfileWithSessionRetry()
fun `failed profile deletion does not restore an identity after sign out`() = test {
val secretKey = "local_secret"
authenticateForTesting(publicKey = VALID_SELF_KEY, secret = "test_session")
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey)
whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY.removePrefix("pubky"))
val deletionStarted = CompletableDeferred<Unit>()
val finishDeletion = CompletableDeferred<Unit>()
val uncertainResponse = TestAppError("Connection closed after profile deletion")
whenever(pubkyService.deletePaykitProfile()).doSuspendableAnswer {
deletionStarted.complete(Unit)
withContext(NonCancellable) { finishDeletion.await() }
throw uncertainResponse
}
val deletion = async { sut.deleteProfile() }
deletionStarted.await()
sut.signOut().getOrThrow()
finishDeletion.complete(Unit)

assertTrue(result.isFailure)
verifyBlocking(pubkyService) { deletePaykitProfile() }
verifyBlocking(pubkyService, never()) { signIn(any()) }
assertSame(uncertainResponse, deletion.await().exceptionOrNull())
assertNull(sut.publicKey.value)
assertFalse(sut.isAuthenticated.value)
verify(pubkyService, never()).signIn(any())
verify(pubkyService).deletePaykitProfile()
}

@Test
Expand Down Expand Up @@ -3466,31 +3476,6 @@ class PubkyRepoTest : BaseUnitTest() {
verify(pubkyStore).reset()
}

@Test
fun `refreshSessionIfPossible should refresh session when local secret key exists`() = test {
val secretKey = "local_secret"
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(secretKey)
whenever(pubkyService.signIn(secretKey)).thenReturn(Unit)
whenever(pubkyService.publicKeyFromSecret(secretKey)).thenReturn(VALID_SELF_KEY.removePrefix("pubky"))

val result = sut.refreshSessionIfPossible()

assertEquals(true, result.getOrNull())
assertEquals(VALID_SELF_KEY, sut.publicKey.value)
assertTrue(sut.isAuthenticated.value)
}

@Test
fun `refreshSessionIfPossible should return false when local secret key is missing`() = test {
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn(null)

val result = sut.refreshSessionIfPossible()

assertEquals(false, result.getOrNull())
assertNull(sut.publicKey.value)
assertFalse(sut.isAuthenticated.value)
}

@Test
fun `restoreSessionBackupState should derive local secret key for local seed backups`() = test {
whenever(keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)).thenReturn("test mnemonic")
Expand Down Expand Up @@ -4394,15 +4379,15 @@ class PubkyRepoTest : BaseUnitTest() {
}

@Test
fun `a contact edit started before the session of its identity is refreshed still saves`() = test {
fun `a contact edit started before the session of its identity is restored still saves`() = test {
val store = stubGatedPubkyStore()
authenticateForTesting(publicKey = VALID_SELF_KEY)
whenever(keychain.loadString(Keychain.Key.PUBKY_SECRET_KEY.name)).thenReturn("local_secret")
whenever(pubkyService.signIn("local_secret")).thenReturn(Unit)
whenever(pubkyService.publicKeyFromSecret("local_secret")).thenReturn(VALID_SELF_KEY.removePrefix("pubky"))
val signIn = checkNotNull(sut.currentSignIn())

assertEquals(true, sut.refreshSessionIfPossible().getOrNull())
sut.initialize().getOrThrow()
val result = sut.updateContact(signIn, VALID_CONTACT_KEY_A, "Alice", "", null, emptyList(), listOf("Friend"))

assertTrue(result.isSuccess)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
sut.showDeleteConfirmation()
advanceUntilIdle()
verify(pubkyRepo, times(1)).forgetUnrestoredIdentity()
verify(pubkyRepo, never()).deleteProfileWithSessionRetry()
verify(pubkyRepo, never()).deleteProfile()
verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), any())
assertFalse(sut.uiState.value.showDeleteDialog)

Expand Down Expand Up @@ -113,7 +113,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
val sut = createSut()
advanceUntilIdle()
val result = CompletableDeferred<Result<Unit>>()
whenever(pubkyRepo.deleteProfileWithSessionRetry()).doSuspendableAnswer { result.await() }
whenever(pubkyRepo.deleteProfile()).doSuspendableAnswer { result.await() }

sut.showDeleteConfirmation()
sut.deleteProfile()
Expand All @@ -129,7 +129,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
sut.disconnectProfile()
sut.showDeleteConfirmation()
advanceUntilIdle()
verify(pubkyRepo, times(1)).deleteProfileWithSessionRetry()
verify(pubkyRepo, times(1)).deleteProfile()
verify(pubkyRepo, never()).saveProfile(any(), any(), any(), any(), any())
verify(pubkyRepo, never()).signOut()
assertFalse(sut.uiState.value.showDeleteDialog)
Expand Down Expand Up @@ -161,7 +161,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
sut.showDeleteConfirmation()
advanceUntilIdle()
verify(pubkyRepo, times(1)).saveProfile(any(), any(), any(), any(), any())
verify(pubkyRepo, never()).deleteProfileWithSessionRetry()
verify(pubkyRepo, never()).deleteProfile()
verify(pubkyRepo, never()).forgetUnrestoredIdentity()
assertFalse(sut.uiState.value.showDeleteDialog)

Expand All @@ -183,7 +183,7 @@ class EditProfileViewModelTest : BaseUnitTest() {

@Test
fun `deleteProfile should emit success when repository delete succeeds`() = test {
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit))
whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit))

val sut = createSut()
advanceUntilIdle()
Expand All @@ -197,14 +197,14 @@ class EditProfileViewModelTest : BaseUnitTest() {
assertFalse(sut.uiState.value.showDeleteFailureDialog)
inOrder(privatePaykitRepo, pubkyRepo).apply {
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
verify(pubkyRepo).deleteProfileWithSessionRetry()
verify(pubkyRepo).deleteProfile()
verify(privatePaykitRepo).closeAndClear()
}
}

@Test
fun `profile created in Bitkit keeps delete copy`() = test {
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit))
whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit))
val sut = createSut()
advanceUntilIdle()

Expand All @@ -218,7 +218,7 @@ class EditProfileViewModelTest : BaseUnitTest() {

@Test
fun `ring identity is unlinked with the same cleanup`() = test {
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit))
whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit))
val sut = createSut(isRingIdentity = true)
advanceUntilIdle()

Expand All @@ -234,7 +234,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
verify(context, never()).getString(R.string.profile__delete_success)
inOrder(privatePaykitRepo, pubkyRepo).apply {
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
verify(pubkyRepo).deleteProfileWithSessionRetry()
verify(pubkyRepo).deleteProfile()
verify(privatePaykitRepo).closeAndClear()
}
}
Expand All @@ -256,7 +256,7 @@ class EditProfileViewModelTest : BaseUnitTest() {

@Test
fun `retryDeleteProfile should emit success when repository delete succeeds`() = test {
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit))
whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit))

val sut = createSut()
advanceUntilIdle()
Expand All @@ -270,14 +270,14 @@ class EditProfileViewModelTest : BaseUnitTest() {
assertFalse(sut.uiState.value.showDeleteFailureDialog)
inOrder(privatePaykitRepo, pubkyRepo).apply {
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
verify(pubkyRepo).deleteProfileWithSessionRetry()
verify(pubkyRepo).deleteProfile()
verify(privatePaykitRepo).closeAndClear()
}
}

@Test
fun `deleteProfile should show retry dialog when delete still fails`() = test {
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(
whenever(pubkyRepo.deleteProfile()).thenReturn(
Result.failure(TestAppError("expired session")),
)

Expand All @@ -295,7 +295,7 @@ class EditProfileViewModelTest : BaseUnitTest() {

@Test
fun `disconnectProfile should emit disconnect success`() = test {
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(
whenever(pubkyRepo.deleteProfile()).thenReturn(
Result.failure(TestAppError("expired session")),
)
whenever(pubkyRepo.signOut()).thenReturn(Result.success(Unit))
Expand Down Expand Up @@ -354,7 +354,7 @@ class EditProfileViewModelTest : BaseUnitTest() {
val sut = createSut()
whenever { privatePaykitRepo.removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true)) }
.thenReturn(Result.failure(TestAppError("cleanup failed")))
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(Result.success(Unit))
whenever(pubkyRepo.deleteProfile()).thenReturn(Result.success(Unit))
advanceUntilIdle()

sut.effects.test {
Expand All @@ -367,14 +367,14 @@ class EditProfileViewModelTest : BaseUnitTest() {
assertFalse(sut.uiState.value.isDeleting)
inOrder(privatePaykitRepo, pubkyRepo).apply {
verify(privatePaykitRepo).removePublishedEndpointsForCleanup(any(), deferAppSync = eq(true))
verify(pubkyRepo).deleteProfileWithSessionRetry()
verify(pubkyRepo).deleteProfile()
verify(privatePaykitRepo).closeAndClear()
}
}

@Test
fun `dismissDeleteFailureDialog should hide retry dialog`() = test {
whenever(pubkyRepo.deleteProfileWithSessionRetry()).thenReturn(
whenever(pubkyRepo.deleteProfile()).thenReturn(
Result.failure(TestAppError("expired session")),
)

Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1688.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Prevent uncertain profile deletion failures from automatically repeating contact cleanup and deletion.
17 changes: 17 additions & 0 deletions journeys/profile/delete-profile-uncertain-response.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<journey name="profile deletion uncertain response">
<description>
Requires a disposable test identity and a controlled homeserver proxy; never use a personal profile.
Configure the proxy to accept one profile deletion but lose its acknowledgement, retaining a redacted
request counter. This is a fault-injection fixture requirement, not evidence that this journey has run.
Normal SDK recovery may retry an individual request only after a confirmed authentication rejection;
the app must not restart contact cleanup and profile deletion because a request has an uncertain outcome.
</description>
<actions>
<action>Open Edit Profile and confirm deletion</action>
<action>Drop the acknowledgement after the server accepts the profile deletion</action>
<action>Verify failure options are shown and no success notification or onboarding navigation occurs</action>
<action>Wait through the network timeout and verify the app starts no second contact cleanup, profile deletion or sign-in</action>
<action>Cancel the failure dialog and verify no mutation is retried</action>
<action>Restart the app without choosing Retry and verify it does not automatically repeat the deletion</action>
</actions>
</journey>
Loading