Skip to content

Commit 5fab306

Browse files
authored
Merge pull request #6595 from nextcloud/improveScheduleDuplicateAccountsForDeletion
fix(account): resolve the active user deterministically when picking which duplicate to keep
2 parents 79c8acb + f4157de commit 5fab306

3 files changed

Lines changed: 54 additions & 10 deletions

File tree

app/src/main/java/com/nextcloud/talk/data/user/UsersDao.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,17 @@ import io.reactivex.Single
2222
@Dao
2323
@Suppress("TooManyFunctions")
2424
abstract class UsersDao {
25-
// get active user
26-
@Query("SELECT * FROM User where current = 1")
25+
// get active user. ORDER BY/LIMIT make this deterministic if more than one row is ever
26+
// marked current=1 (e.g. a duplicate-account row left over from a past bug), instead of
27+
// relying on whatever order an unordered full-table scan happens to return.
28+
@Query("SELECT * FROM User where current = 1 ORDER BY id DESC LIMIT 1")
2729
abstract fun getActiveUser(): Maybe<UserEntity>
2830

2931
// get active user
30-
@Query("SELECT * FROM User where current = 1")
32+
@Query("SELECT * FROM User where current = 1 ORDER BY id DESC LIMIT 1")
3133
abstract fun getActiveUserObservable(): Observable<UserEntity>
3234

33-
@Query("SELECT * FROM User where current = 1")
35+
@Query("SELECT * FROM User where current = 1 ORDER BY id DESC LIMIT 1")
3436
abstract fun getActiveUserSynchronously(): UserEntity?
3537

3638
@Delete

app/src/main/java/com/nextcloud/talk/users/UserManager.kt

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,22 +76,34 @@ class UserManager internal constructor(private val userRepository: UsersReposito
7676

7777
/**
7878
* If there is more than one local User row for the same username+baseUrl (e.g. reusing the
79-
* same token): Keep the current user if it's one of the duplicates, otherwise the oldest (lowest id) row,
80-
* and schedules the rest for deletion so AccountRemovalWorker cleans them up like any other removed account.
79+
* same token): keep whichever row [UsersRepository.getActiveUser] actually resolves to if
80+
* it's one of the duplicates, otherwise a duplicate marked current, otherwise the oldest
81+
* (lowest id) row, and schedules the rest for deletion so AccountRemovalWorker cleans them up
82+
* like any other removed account.
83+
*
84+
* The active user is resolved first, rather than trusting each row's own `current` flag,
85+
* because a past(?) bug could leave more than one row marked current=true for the same account.
86+
* Picking a duplicate to keep by its `current` flag alone could then disagree with whichever
87+
* row a live session/background sync is still bound to, and deleting that row here would trip
88+
* a foreign key constraint on any in-flight write still referencing it.
8189
*
8290
* @return the number of duplicate rows scheduled for deletion
8391
*/
8492
fun scheduleDuplicateAccountsForDeletion(): Single<Int> =
85-
users.map { allUsers ->
86-
allUsers
93+
Single.zip(
94+
users,
95+
userRepository.getActiveUser().map { it.id }.toSingle(NO_ACTIVE_USER_ID)
96+
) { allUsers, activeUserId ->
97+
val duplicateGroups = allUsers
8798
.filter { !it.username.isNullOrEmpty() && !it.baseUrl.isNullOrEmpty() }
8899
.groupBy { it.username to it.baseUrl }
89100
.values
90101
.filter { it.size > 1 }
91-
}.map { duplicateGroups ->
102+
92103
var scheduledCount = 0
93104
duplicateGroups.forEach { duplicates ->
94-
val userToKeep = duplicates.firstOrNull { it.current }
105+
val userToKeep = duplicates.firstOrNull { it.id == activeUserId }
106+
?: duplicates.firstOrNull { it.current }
95107
?: duplicates.minByOrNull { it.id ?: Long.MAX_VALUE }
96108
duplicates
97109
.filter { it.id != userToKeep?.id }
@@ -248,6 +260,7 @@ class UserManager internal constructor(private val userRepository: UsersReposito
248260

249261
companion object {
250262
const val TAG = "UserManager"
263+
private const val NO_ACTIVE_USER_ID = -1L
251264
}
252265

253266
data class UserAttributes(

app/src/test/java/com/nextcloud/talk/users/UserManagerTest.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ package com.nextcloud.talk.users
88

99
import com.nextcloud.talk.data.user.UsersRepository
1010
import com.nextcloud.talk.data.user.model.User
11+
import io.reactivex.Maybe
1112
import io.reactivex.Single
1213
import org.junit.Assert.assertEquals
1314
import org.junit.Assert.assertFalse
1415
import org.junit.Assert.assertTrue
16+
import org.junit.Before
1517
import org.junit.Test
1618
import org.mockito.kotlin.mock
1719
import org.mockito.kotlin.verify
@@ -25,6 +27,14 @@ class UserManagerTest {
2527
private fun user(id: Long, username: String, baseUrl: String, current: Boolean = false) =
2628
User(id = id, username = username, baseUrl = baseUrl, current = current)
2729

30+
@Before
31+
fun setUp() {
32+
// No row resolves as "the" active user unless a test overrides this, so
33+
// scheduleDuplicateAccountsForDeletion() falls back to the `current` flag / oldest row,
34+
// matching the behavior asserted by the tests below that don't care about this priority.
35+
whenever(usersRepository.getActiveUser()).thenReturn(Maybe.empty())
36+
}
37+
2838
@Test
2939
fun `keeps the current user among duplicates and schedules the rest for deletion`() {
3040
val current = user(id = 2, username = "userA", baseUrl = "https://example.com", current = true)
@@ -135,4 +145,23 @@ class UserManagerTest {
135145

136146
assertEquals(0, scheduledCount)
137147
}
148+
149+
@Test
150+
fun `keeps whichever row getActiveUser resolves to, even over a different row flagged current`() {
151+
// Simulates a past bug leaving two rows marked current=true for the same account: the
152+
// active-user lookup (deterministically) resolves to one of them, but the other still
153+
// carries the current flag too. The actively-resolved row must win, since it may be the
154+
// one a live session/background sync is still bound to.
155+
val staleCurrentFlag = user(id = 1, username = "userA", baseUrl = "https://example.com", current = true)
156+
val actuallyActive = user(id = 2, username = "userA", baseUrl = "https://example.com", current = true)
157+
whenever(usersRepository.getUsers()).thenReturn(Single.just(listOf(staleCurrentFlag, actuallyActive)))
158+
whenever(usersRepository.getActiveUser()).thenReturn(Maybe.just(actuallyActive))
159+
160+
val scheduledCount = userManager.scheduleDuplicateAccountsForDeletion().blockingGet()
161+
162+
assertEquals(1, scheduledCount)
163+
assertTrue(staleCurrentFlag.scheduledForDeletion)
164+
assertFalse(actuallyActive.scheduledForDeletion)
165+
verify(usersRepository).updateUser(staleCurrentFlag)
166+
}
138167
}

0 commit comments

Comments
 (0)