BIT-746, BIT-1120: Implement session timeout functionality (#605)

This commit is contained in:
Brian Yencho 2024-01-14 17:30:44 -06:00 committed by Álison Fernandes
parent 6a208fee31
commit d6d179a27f
11 changed files with 631 additions and 217 deletions

View file

@ -184,22 +184,12 @@ class AuthRepositoryImpl constructor(
when (loginResponse) {
is CaptchaRequired -> LoginResult.CaptchaRequired(loginResponse.captchaKey)
is Success -> {
activeUserId?.let { previousActiveUserId ->
vaultRepository.lockVaultIfNecessary(userId = previousActiveUserId)
}
val userStateJson = loginResponse.toUserState(
previousUserState = authDiskSource.userState,
environmentUrlData = environmentRepository
.environment
.environmentUrlData,
)
// Check for existing organization keys for a soft-logout account.
// We can separately unlock the vault for organization data after receiving
// the sync response if this data is currently absent.
val organizationKeys =
authDiskSource.getOrganizationKeys(
userId = userStateJson.activeUserId,
)
vaultRepository.clearUnlockedData()
vaultRepository.unlockVault(
userId = userStateJson.activeUserId,
@ -208,7 +198,9 @@ class AuthRepositoryImpl constructor(
userKey = loginResponse.key,
privateKey = loginResponse.privateKey,
masterPassword = password,
organizationKeys = organizationKeys,
// We can separately unlock the vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
authDiskSource.userState = userStateJson
authDiskSource.storeUserKey(
@ -286,8 +278,7 @@ class AuthRepositoryImpl constructor(
// Switch to the new user
authDiskSource.userState = currentUserState.copy(activeUserId = userId)
// Lock and clear data for the previous user
vaultRepository.lockVaultIfNecessary(previousActiveUserId)
// Clear data for the previous user
vaultRepository.clearUnlockedData()
// Clear any special circumstances

View file

@ -30,11 +30,6 @@ interface VaultLockManager {
*/
fun lockVault(userId: String)
/**
* Locks the vault for the user with the given [userId] only if necessary.
*/
fun lockVaultIfNecessary(userId: String)
/**
* Locks the vault for the current user if currently unlocked.
*/

View file

@ -1,14 +1,19 @@
package com.x8bit.bitwarden.data.vault.manager
import android.os.SystemClock
import com.bitwarden.core.InitOrgCryptoRequest
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.core.Kdf
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@ -27,25 +32,33 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
private const val SECONDS_PER_MINUTE = 60
private const val MILLISECONDS_PER_SECOND = 1000
/**
* Primary implementation [VaultLockManager].
*/
@Suppress("TooManyFunctions")
@Suppress("TooManyFunctions", "LongParameterList")
class VaultLockManagerImpl(
private val authDiskSource: AuthDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val settingsRepository: SettingsRepository,
private val appForegroundManager: AppForegroundManager,
private val userLogoutManager: UserLogoutManager,
private val dispatcherManager: DispatcherManager,
private val elapsedRealtimeMillisProvider: () -> Long = { SystemClock.elapsedRealtime() },
) : VaultLockManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
private val userIds: Set<String> get() = authDiskSource.userState?.accounts?.keys.orEmpty()
private val mutableVaultStateStateFlow =
MutableStateFlow(
@ -59,6 +72,8 @@ class VaultLockManagerImpl(
get() = mutableVaultStateStateFlow.asStateFlow()
init {
observeAppForegroundChanges()
observeUserSwitchingChanges()
observeVaultTimeoutChanges()
}
@ -78,15 +93,6 @@ class VaultLockManagerImpl(
}
}
override fun lockVaultIfNecessary(userId: String) {
// Don't lock the vault for users with a Never Lock timeout.
val hasNeverLockTimeout =
settingsRepository.getVaultTimeoutStateFlow(userId = userId).value == VaultTimeout.Never
if (hasNeverLockTimeout) return
lockVault(userId = userId)
}
override suspend fun unlockVault(
userId: String,
email: String,
@ -200,6 +206,61 @@ class VaultLockManagerImpl(
}
}
private fun observeAppForegroundChanges() {
var isFirstForeground = true
appForegroundManager
.appForegroundStateFlow
.onEach { appForegroundState ->
when (appForegroundState) {
AppForegroundState.BACKGROUNDED -> {
activeUserId?.let { updateLastActiveTime(userId = it) }
}
AppForegroundState.FOREGROUNDED -> {
userIds.forEach { userId ->
// If first foreground, clear the elapsed values so the timeout action
// is always performed.
if (isFirstForeground) {
authDiskSource.storeLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = null,
)
}
checkForVaultTimeout(
userId = userId,
isAppRestart = isFirstForeground,
)
}
isFirstForeground = false
}
}
}
.launchIn(unconfinedScope)
}
private fun observeUserSwitchingChanges() {
var lastActiveUserId: String? = null
authDiskSource
.userStateFlow
.mapNotNull { it?.activeUserId }
.distinctUntilChanged()
.onEach { activeUserId ->
val previousActiveUserId = lastActiveUserId
lastActiveUserId = activeUserId
if (previousActiveUserId != null &&
activeUserId != previousActiveUserId
) {
handleUserSwitch(
previousActiveUserId = previousActiveUserId,
currentActiveUserId = activeUserId,
)
}
}
.launchIn(unconfinedScope)
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeVaultTimeoutChanges() {
authDiskSource
@ -264,6 +325,88 @@ class VaultLockManagerImpl(
}
}
/**
* Handles any vault timeout actions that may need to be performed for the given
* [previousActiveUserId] and [currentActiveUserId] during an account switch.
*/
private fun handleUserSwitch(
previousActiveUserId: String,
currentActiveUserId: String,
) {
// Check if the user's timeout action should be performed as we switch away.
checkForVaultTimeout(userId = previousActiveUserId)
// Set the last active time for the previous user.
updateLastActiveTime(userId = previousActiveUserId)
// Check if the vault timeout action should be performed for the current user
checkForVaultTimeout(userId = currentActiveUserId)
// Set the last active time for the current user.
updateLastActiveTime(userId = currentActiveUserId)
}
/**
* Checks the current [VaultTimeout] for the given [userId]. If the given timeout value has
* been exceeded, the [VaultTimeoutAction] for the given user will be performed.
*/
@Suppress("ReturnCount")
private fun checkForVaultTimeout(
userId: String,
isAppRestart: Boolean = false,
) {
val currentTimeMillis = elapsedRealtimeMillisProvider()
val lastActiveTimeMillis =
authDiskSource
.getLastActiveTimeMillis(userId = userId)
?: 0
val vaultTimeout =
settingsRepository.getVaultTimeoutStateFlow(userId = userId).value
val vaultTimeoutAction =
settingsRepository.getVaultTimeoutActionStateFlow(userId = userId).value
val vaultTimeoutInMinutes = when (vaultTimeout) {
VaultTimeout.Never -> {
// No action to take for Never timeout.
return
}
VaultTimeout.OnAppRestart -> {
// If this is an app restart, trigger the timeout action; otherwise ignore.
if (isAppRestart) 0 else return
}
else -> vaultTimeout.vaultTimeoutInMinutes ?: return
}
val vaultTimeoutInMillis = vaultTimeoutInMinutes *
SECONDS_PER_MINUTE *
MILLISECONDS_PER_SECOND
if (currentTimeMillis - lastActiveTimeMillis >= vaultTimeoutInMillis) {
// Perform lock / logout!
when (vaultTimeoutAction) {
VaultTimeoutAction.LOCK -> {
setVaultToLocked(userId = userId)
}
VaultTimeoutAction.LOGOUT -> {
setVaultToLocked(userId = userId)
userLogoutManager.softLogout(userId = userId)
}
}
}
}
/**
* Sets the "last active time" for the given [userId] to the current time.
*/
private fun updateLastActiveTime(userId: String) {
val elapsedRealtimeMillis = elapsedRealtimeMillisProvider()
authDiskSource.storeLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = elapsedRealtimeMillis,
)
}
@Suppress("ReturnCount")
private suspend fun unlockVaultForUser(
userId: String,

View file

@ -1,6 +1,8 @@
package com.x8bit.bitwarden.data.vault.manager.di
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@ -25,12 +27,16 @@ object VaultManagerModule {
authDiskSource: AuthDiskSource,
vaultSdkSource: VaultSdkSource,
settingsRepository: SettingsRepository,
appForegroundManager: AppForegroundManager,
userLogoutManager: UserLogoutManager,
dispatcherManager: DispatcherManager,
): VaultLockManager =
VaultLockManagerImpl(
authDiskSource = authDiskSource,
vaultSdkSource = vaultSdkSource,
settingsRepository = settingsRepository,
appForegroundManager = appForegroundManager,
userLogoutManager = userLogoutManager,
dispatcherManager = dispatcherManager,
)
}

View file

@ -159,9 +159,6 @@ class AccountSecurityViewModel @Inject constructor(
)
}
settingsRepository.vaultTimeout = vaultTimeout
// TODO: Finish implementing vault timeouts (BIT-1120)
sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText()))
}
private fun handleVaultTimeoutActionSelect(
@ -174,9 +171,6 @@ class AccountSecurityViewModel @Inject constructor(
)
}
settingsRepository.vaultTimeoutAction = vaultTimeoutAction
// TODO BIT-746: Finish implementing session timeout action
sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText()))
}
private fun handleTwoStepLoginClick() {

View file

@ -129,6 +129,13 @@ class FakeAuthDiskSource : AuthDiskSource {
assertEquals(userState, this.userState)
}
/**
* Assert that the [lastActiveTimeMillis] was stored successfully using the [userId].
*/
fun assertLastActiveTimeMillis(userId: String, lastActiveTimeMillis: Long?) {
assertEquals(lastActiveTimeMillis, storedLastActiveTimeMillis[userId])
}
/**
* Assert that the [userKey] was stored successfully using the [userId].
*/

View file

@ -82,7 +82,6 @@ class AuthRepositoryTest {
private val vaultRepository: VaultRepository = mockk {
every { vaultStateFlow } returns mutableVaultStateFlow
every { deleteVaultData(any()) } just runs
every { lockVaultIfNecessary(any()) } just runs
every { clearUnlockedData() } just runs
}
private val fakeAuthDiskSource = FakeAuthDiskSource()
@ -559,7 +558,6 @@ class AuthRepositoryTest {
)
assertNull(repository.specialCircumstance)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify(exactly = 0) { vaultRepository.lockVaultIfNecessary(any()) }
verify { vaultRepository.clearUnlockedData() }
}
@ -641,90 +639,6 @@ class AuthRepositoryTest {
)
assertNull(repository.specialCircumstance)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify { vaultRepository.lockVaultIfNecessary(userId = USER_ID_2) }
verify { vaultRepository.clearUnlockedData() }
}
@Suppress("MaxLineLength")
@Test
fun `login get token succeeds when the current user is in a soft-logout state should use existing organization keys when unlocking the vault`() =
runTest {
val successResponse = GET_TOKEN_RESPONSE_SUCCESS
coEvery {
accountsService.preLogin(email = EMAIL)
} returns Result.success(PRE_LOGIN_SUCCESS)
coEvery {
identityService.getToken(
email = EMAIL,
passwordHash = PASSWORD_HASH,
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
}
.returns(Result.success(successResponse))
coEvery {
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
privateKey = successResponse.privateKey,
organizationKeys = ORGANIZATION_KEYS,
masterPassword = PASSWORD,
)
} returns VaultUnlockResult.Success
coEvery { vaultRepository.sync() } just runs
every {
GET_TOKEN_RESPONSE_SUCCESS.toUserState(
previousUserState = null,
environmentUrlData = EnvironmentUrlDataJson.DEFAULT_US,
)
} returns SINGLE_USER_STATE_1
// Users in a soft-logout state have some existing data stored to disk from previous
// sync requests.
fakeAuthDiskSource.storeOrganizationKeys(
userId = USER_ID_1,
organizationKeys = ORGANIZATION_KEYS,
)
val result = repository.login(email = EMAIL, password = PASSWORD, captchaToken = null)
assertEquals(LoginResult.Success, result)
assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value)
coVerify { accountsService.preLogin(email = EMAIL) }
fakeAuthDiskSource.assertPrivateKey(
userId = USER_ID_1,
privateKey = "privateKey",
)
fakeAuthDiskSource.assertUserKey(
userId = USER_ID_1,
userKey = "key",
)
coVerify {
identityService.getToken(
email = EMAIL,
passwordHash = PASSWORD_HASH,
captchaToken = null,
uniqueAppId = UNIQUE_APP_ID,
)
vaultRepository.unlockVault(
userId = USER_ID_1,
email = EMAIL,
kdf = ACCOUNT_1.profile.toSdkParams(),
userKey = successResponse.key,
privateKey = successResponse.privateKey,
organizationKeys = ORGANIZATION_KEYS,
masterPassword = PASSWORD,
)
vaultRepository.sync()
}
assertEquals(
SINGLE_USER_STATE_1,
fakeAuthDiskSource.userState,
)
assertNull(repository.specialCircumstance)
verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) }
verify(exactly = 0) { vaultRepository.lockVaultIfNecessary(any()) }
verify { vaultRepository.clearUnlockedData() }
}
@ -1129,7 +1043,6 @@ class AuthRepositoryTest {
)
assertNull(repository.userStateFlow.value)
verify(exactly = 0) { vaultRepository.lockVaultIfNecessary(any()) }
verify(exactly = 0) { vaultRepository.clearUnlockedData() }
}
@ -1159,7 +1072,6 @@ class AuthRepositoryTest {
repository.userStateFlow.value,
)
assertNull(repository.specialCircumstance)
verify(exactly = 0) { vaultRepository.lockVaultIfNecessary(originalUserId) }
verify(exactly = 0) { vaultRepository.clearUnlockedData() }
}
@ -1188,13 +1100,12 @@ class AuthRepositoryTest {
originalUserState,
repository.userStateFlow.value,
)
verify(exactly = 0) { vaultRepository.lockVaultIfNecessary(originalUserId) }
verify(exactly = 0) { vaultRepository.clearUnlockedData() }
}
@Suppress("MaxLineLength")
@Test
fun `switchAccount when the userId is valid should update the current UserState, lock the vault of the previous active user, clear the previously unlocked data, and reset the special circumstance`() {
fun `switchAccount when the userId is valid should update the current UserState, clear the previously unlocked data, and reset the special circumstance`() {
val originalUserId = USER_ID_1
val updatedUserId = USER_ID_2
val originalUserState = MULTI_USER_STATE.toUserState(
@ -1219,7 +1130,6 @@ class AuthRepositoryTest {
repository.userStateFlow.value,
)
assertNull(repository.specialCircumstance)
verify { vaultRepository.lockVaultIfNecessary(originalUserId) }
verify { vaultRepository.clearUnlockedData() }
}

View file

@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.platform.manager.util
import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* A faked implementation of [AppForegroundManager]
*/
class FakeAppForegroundManager : AppForegroundManager {
private val mutableAppForegroundStateFlow = MutableStateFlow(AppForegroundState.BACKGROUNDED)
override val appForegroundStateFlow: StateFlow<AppForegroundState>
get() = mutableAppForegroundStateFlow.asStateFlow()
/**
* The current [AppForegroundState] tracked by the [appForegroundStateFlow].
*/
var appForegroundState: AppForegroundState
get() = mutableAppForegroundStateFlow.value
set(value) {
mutableAppForegroundStateFlow.value = value
}
}

View file

@ -6,10 +6,14 @@ import com.bitwarden.core.InitUserCryptoRequest
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
import com.x8bit.bitwarden.data.platform.manager.util.FakeAppForegroundManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@ -17,6 +21,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResul
import com.x8bit.bitwarden.data.vault.repository.model.VaultState
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import io.mockk.awaits
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@ -26,6 +31,7 @@ import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@ -35,22 +41,396 @@ import org.junit.jupiter.api.Test
@Suppress("LargeClass")
class VaultLockManagerTest {
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val fakeAppForegroundManager = FakeAppForegroundManager()
private val vaultSdkSource: VaultSdkSource = mockk {
every { clearCrypto(userId = any()) } just runs
}
private val userLogoutManager: UserLogoutManager = mockk {
every { logout(any()) } just runs
every { softLogout(any()) } just runs
}
private val mutableVaultTimeoutStateFlow =
MutableStateFlow<VaultTimeout>(VaultTimeout.ThirtyMinutes)
private val mutableVaultTimeoutActionStateFlow =
MutableStateFlow<VaultTimeoutAction>(VaultTimeoutAction.LOCK)
private val settingsRepository: SettingsRepository = mockk {
every { getVaultTimeoutStateFlow(any()) } returns mutableVaultTimeoutStateFlow
every { getVaultTimeoutActionStateFlow(any()) } returns mutableVaultTimeoutActionStateFlow
}
private var elapsedRealtimeMillis = 123456789L
private val vaultLockManager: VaultLockManager = VaultLockManagerImpl(
authDiskSource = fakeAuthDiskSource,
vaultSdkSource = vaultSdkSource,
settingsRepository = settingsRepository,
appForegroundManager = fakeAppForegroundManager,
userLogoutManager = userLogoutManager,
dispatcherManager = FakeDispatcherManager(),
elapsedRealtimeMillisProvider = { elapsedRealtimeMillis },
)
@Test
fun `app going into background should update the current user's last active time`() {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
// Start in a foregrounded state
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
fakeAuthDiskSource.assertLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = null,
)
elapsedRealtimeMillis = 123L
fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED
fakeAuthDiskSource.assertLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = 123L,
)
}
@Suppress("MaxLineLength")
@Test
fun `app coming into foreground for the first time for Never timeout should clear existing times and not perform timeout action`() {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOCK
mutableVaultTimeoutStateFlow.value = VaultTimeout.Never
fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED
fakeAuthDiskSource.storeLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = 123L,
)
verifyUnlockedVaultBlocking(userId = userId)
assertTrue(vaultLockManager.isVaultUnlocked(userId))
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
assertTrue(vaultLockManager.isVaultUnlocked(userId))
fakeAuthDiskSource.assertLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = null,
)
}
@Suppress("MaxLineLength")
@Test
fun `app coming into foreground for the first time for OnAppRestart timeout should clear existing times and lock vaults if necessary`() {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOCK
mutableVaultTimeoutStateFlow.value = VaultTimeout.OnAppRestart
fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED
fakeAuthDiskSource.storeLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = 123L,
)
verifyUnlockedVaultBlocking(userId = userId)
assertTrue(vaultLockManager.isVaultUnlocked(userId))
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
assertFalse(vaultLockManager.isVaultUnlocked(userId))
fakeAuthDiskSource.assertLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = null,
)
}
@Suppress("MaxLineLength")
@Test
fun `app coming into foreground for the first time for other timeout should clear existing times and lock vaults if necessary`() {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOCK
mutableVaultTimeoutStateFlow.value = VaultTimeout.ThirtyMinutes
fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED
fakeAuthDiskSource.storeLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = 123L,
)
verifyUnlockedVaultBlocking(userId = userId)
assertTrue(vaultLockManager.isVaultUnlocked(userId))
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
assertFalse(vaultLockManager.isVaultUnlocked(userId))
fakeAuthDiskSource.assertLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = null,
)
}
@Suppress("MaxLineLength")
@Test
fun `app coming into foreground for the first time for non-Never timeout should clear existing times and perform timeout action`() {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOCK
mutableVaultTimeoutStateFlow.value = VaultTimeout.ThirtyMinutes
fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED
fakeAuthDiskSource.storeLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = 123L,
)
verifyUnlockedVaultBlocking(userId = userId)
assertTrue(vaultLockManager.isVaultUnlocked(userId))
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
assertFalse(vaultLockManager.isVaultUnlocked(userId))
fakeAuthDiskSource.assertLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = null,
)
}
@Suppress("MaxLineLength")
@Test
fun `app coming into foreground subsequent times should perform timeout action if necessary and not clear existing times`() {
val userId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
// Start in a foregrounded state
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
fakeAuthDiskSource.assertLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = null,
)
// Set the last active time to 2 minutes and the current time to 8 minutes, so only times
// beyond 6 minutes perform their action.
val lastActiveTime = 2 * 60 * 1000L
elapsedRealtimeMillis = 8 * 60 * 1000L
// Will be used within each loop to reset the test to a suitable initial state.
fun resetTest(vaultTimeout: VaultTimeout) {
clearVerifications(userLogoutManager)
mutableVaultTimeoutStateFlow.value = vaultTimeout
fakeAppForegroundManager.appForegroundState = AppForegroundState.BACKGROUNDED
fakeAuthDiskSource.storeLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = lastActiveTime,
)
verifyUnlockedVaultBlocking(userId = userId)
assertTrue(vaultLockManager.isVaultUnlocked(userId))
}
// Test Lock action
mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOCK
MOCK_TIMEOUTS.forEach { vaultTimeout ->
resetTest(vaultTimeout = vaultTimeout)
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
when (vaultTimeout) {
// After 6 minutes (or action should not be performed)
VaultTimeout.Never,
VaultTimeout.OnAppRestart,
VaultTimeout.FifteenMinutes,
VaultTimeout.ThirtyMinutes,
VaultTimeout.OneHour,
VaultTimeout.FourHours,
is VaultTimeout.Custom,
-> {
assertTrue(vaultLockManager.isVaultUnlocked(userId))
}
// Before 6 minutes
VaultTimeout.Immediately,
VaultTimeout.OneMinute,
VaultTimeout.FiveMinutes,
-> {
assertFalse(vaultLockManager.isVaultUnlocked(userId))
}
}
verify(exactly = 0) { userLogoutManager.softLogout(any()) }
fakeAuthDiskSource.assertLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = lastActiveTime,
)
}
// Test Logout action
mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOGOUT
MOCK_TIMEOUTS.forEach { vaultTimeout ->
resetTest(vaultTimeout = vaultTimeout)
fakeAppForegroundManager.appForegroundState = AppForegroundState.FOREGROUNDED
when (vaultTimeout) {
// After 6 minutes (or action should not be performed)
VaultTimeout.Never,
VaultTimeout.OnAppRestart,
VaultTimeout.FifteenMinutes,
VaultTimeout.ThirtyMinutes,
VaultTimeout.OneHour,
VaultTimeout.FourHours,
is VaultTimeout.Custom,
-> {
assertTrue(vaultLockManager.isVaultUnlocked(userId))
verify(exactly = 0) { userLogoutManager.softLogout(any()) }
}
// Before 6 minutes
VaultTimeout.Immediately,
VaultTimeout.OneMinute,
VaultTimeout.FiveMinutes,
-> {
assertFalse(vaultLockManager.isVaultUnlocked(userId))
verify(exactly = 1) { userLogoutManager.softLogout(userId) }
}
}
fakeAuthDiskSource.assertLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = lastActiveTime,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `switching users should perform lock actions for each user if necessary and reset their last active times`() {
val userId1 = "mockId-1"
val userId2 = "mockId-2"
fakeAuthDiskSource.userState = UserStateJson(
activeUserId = userId1,
accounts = mapOf(
userId1 to MOCK_ACCOUNT,
userId2 to MOCK_ACCOUNT.copy(profile = MOCK_PROFILE.copy(userId = userId2)),
),
)
// Set the last active time to 2 minutes and the current time to 8 minutes, so only times
// beyond 6 minutes perform their action.
val lastActiveTime = 2 * 60 * 1000L
elapsedRealtimeMillis = 8 * 60 * 1000L
// Will be used within each loop to reset the test to a suitable initial state.
fun resetTest(vaultTimeout: VaultTimeout) {
clearVerifications(userLogoutManager)
mutableVaultTimeoutStateFlow.value = vaultTimeout
fakeAuthDiskSource.storeLastActiveTimeMillis(
userId = userId1,
lastActiveTimeMillis = lastActiveTime,
)
fakeAuthDiskSource.storeLastActiveTimeMillis(
userId = userId2,
lastActiveTimeMillis = lastActiveTime,
)
verifyUnlockedVaultBlocking(userId = userId1)
verifyUnlockedVaultBlocking(userId = userId2)
assertTrue(vaultLockManager.isVaultUnlocked(userId1))
assertTrue(vaultLockManager.isVaultUnlocked(userId2))
}
// Test Lock action
mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOCK
MOCK_TIMEOUTS.forEach { vaultTimeout ->
resetTest(vaultTimeout = vaultTimeout)
fakeAuthDiskSource.userState = fakeAuthDiskSource.userState?.copy(
activeUserId = if (fakeAuthDiskSource.userState?.activeUserId == userId1) {
userId2
} else {
userId1
},
)
when (vaultTimeout) {
// After 6 minutes (or action should not be performed)
VaultTimeout.Never,
VaultTimeout.OnAppRestart,
VaultTimeout.FifteenMinutes,
VaultTimeout.ThirtyMinutes,
VaultTimeout.OneHour,
VaultTimeout.FourHours,
is VaultTimeout.Custom,
-> {
assertTrue(vaultLockManager.isVaultUnlocked(userId1))
assertTrue(vaultLockManager.isVaultUnlocked(userId2))
}
// Before 6 minutes
VaultTimeout.Immediately,
VaultTimeout.OneMinute,
VaultTimeout.FiveMinutes,
-> {
assertFalse(vaultLockManager.isVaultUnlocked(userId1))
assertFalse(vaultLockManager.isVaultUnlocked(userId2))
}
}
verify(exactly = 0) { userLogoutManager.softLogout(any()) }
fakeAuthDiskSource.assertLastActiveTimeMillis(
userId = userId1,
lastActiveTimeMillis = elapsedRealtimeMillis,
)
fakeAuthDiskSource.assertLastActiveTimeMillis(
userId = userId2,
lastActiveTimeMillis = elapsedRealtimeMillis,
)
}
// Test Logout action
mutableVaultTimeoutActionStateFlow.value = VaultTimeoutAction.LOGOUT
MOCK_TIMEOUTS.forEach { vaultTimeout ->
resetTest(vaultTimeout = vaultTimeout)
fakeAuthDiskSource.userState = fakeAuthDiskSource.userState?.copy(
activeUserId = if (fakeAuthDiskSource.userState?.activeUserId == userId1) {
userId2
} else {
userId1
},
)
when (vaultTimeout) {
// After 6 minutes (or action should not be performed)
VaultTimeout.Never,
VaultTimeout.OnAppRestart,
VaultTimeout.FifteenMinutes,
VaultTimeout.ThirtyMinutes,
VaultTimeout.OneHour,
VaultTimeout.FourHours,
is VaultTimeout.Custom,
-> {
assertTrue(vaultLockManager.isVaultUnlocked(userId1))
assertTrue(vaultLockManager.isVaultUnlocked(userId2))
verify(exactly = 0) { userLogoutManager.softLogout(any()) }
}
// Before 6 minutes
VaultTimeout.Immediately,
VaultTimeout.OneMinute,
VaultTimeout.FiveMinutes,
-> {
assertFalse(vaultLockManager.isVaultUnlocked(userId1))
assertFalse(vaultLockManager.isVaultUnlocked(userId2))
verify(exactly = 1) { userLogoutManager.softLogout(userId1) }
verify(exactly = 1) { userLogoutManager.softLogout(userId2) }
}
}
fakeAuthDiskSource.assertLastActiveTimeMillis(
userId = userId1,
lastActiveTimeMillis = elapsedRealtimeMillis,
)
fakeAuthDiskSource.assertLastActiveTimeMillis(
userId = userId2,
lastActiveTimeMillis = elapsedRealtimeMillis,
)
}
}
@Test
fun `vaultTimeout updates to non-Never should clear the user's auto-unlock key`() = runTest {
val userId = "mockId-1"
@ -271,59 +651,6 @@ class VaultLockManagerTest {
verify { vaultSdkSource.clearCrypto(userId = userId) }
}
@Suppress("MaxLineLength")
@Test
fun `lockVaultIfNecessary when non-Never timeout should lock the given account if it is currently unlocked`() =
runTest {
val userId = "userId"
verifyUnlockedVault(userId = userId)
assertEquals(
VaultState(
unlockedVaultUserIds = setOf(userId),
unlockingVaultUserIds = emptySet(),
),
vaultLockManager.vaultStateFlow.value,
)
vaultLockManager.lockVaultIfNecessary(userId = userId)
assertEquals(
VaultState(
unlockedVaultUserIds = emptySet(),
unlockingVaultUserIds = emptySet(),
),
vaultLockManager.vaultStateFlow.value,
)
verify { vaultSdkSource.clearCrypto(userId = userId) }
}
@Suppress("MaxLineLength")
@Test
fun `lockVaultIfNecessary when Never timeout should not lock the given account if it is currently unlocked`() =
runTest {
val userId = "userId"
verifyUnlockedVault(userId = userId)
mutableVaultTimeoutStateFlow.value = VaultTimeout.Never
val initialVaultState = VaultState(
unlockedVaultUserIds = setOf(userId),
unlockingVaultUserIds = emptySet(),
)
assertEquals(
initialVaultState,
vaultLockManager.vaultStateFlow.value,
)
vaultLockManager.lockVaultIfNecessary(userId = userId)
assertEquals(
initialVaultState,
vaultLockManager.vaultStateFlow.value,
)
verify(exactly = 0) { vaultSdkSource.clearCrypto(userId = userId) }
}
@Suppress("MaxLineLength")
@Test
fun `lockVaultForCurrentUser should lock the vault for the current user if it is currently unlocked`() =
@ -840,6 +1167,21 @@ class VaultLockManagerTest {
}
}
/**
* Resets the verification call count for the given [mock] while leaving all other mocked
* behavior in place.
*/
private fun clearVerifications(mock: Any) {
clearMocks(
firstMock = mock,
recordedCalls = true,
answers = false,
childMocks = false,
verificationMarks = false,
exclusionRules = false,
)
}
/**
* Helper to ensures that the vault for the user with the given [userId] is actively unlocking.
* Note that this call will actively hang.
@ -889,6 +1231,10 @@ class VaultLockManagerTest {
val userKey = "12345"
val privateKey = "54321"
val organizationKeys = null
val userAutoUnlockKey = "userAutoUnlockKey"
// Clear recorded calls so this helper can be called multiple times and assert a unique
// unlock has happened each time.
clearVerifications(vaultSdkSource)
coEvery {
vaultSdkSource.initializeCrypto(
userId = userId,
@ -903,6 +1249,9 @@ class VaultLockManagerTest {
),
)
} returns InitializeCryptoResult.Success.asSuccess()
coEvery {
vaultSdkSource.getUserEncryptionKey(userId = userId)
} returns userAutoUnlockKey.asSuccess()
val result = vaultLockManager.unlockVault(
userId = userId,
@ -932,6 +1281,25 @@ class VaultLockManagerTest {
)
}
}
private fun verifyUnlockedVaultBlocking(userId: String) {
runBlocking { verifyUnlockedVault(userId = userId) }
}
}
private val MOCK_TIMEOUTS = VaultTimeout.Type.entries.map {
when (it) {
VaultTimeout.Type.IMMEDIATELY -> VaultTimeout.Immediately
VaultTimeout.Type.ONE_MINUTE -> VaultTimeout.OneMinute
VaultTimeout.Type.FIVE_MINUTES -> VaultTimeout.FiveMinutes
VaultTimeout.Type.FIFTEEN_MINUTES -> VaultTimeout.FifteenMinutes
VaultTimeout.Type.THIRTY_MINUTES -> VaultTimeout.ThirtyMinutes
VaultTimeout.Type.ONE_HOUR -> VaultTimeout.OneHour
VaultTimeout.Type.FOUR_HOURS -> VaultTimeout.FourHours
VaultTimeout.Type.ON_APP_RESTART -> VaultTimeout.OnAppRestart
VaultTimeout.Type.NEVER -> VaultTimeout.Never
VaultTimeout.Type.CUSTOM -> VaultTimeout.Custom(vaultTimeoutInMinutes = 123)
}
}
private val MOCK_PROFILE = AccountJson.Profile(

View file

@ -100,7 +100,6 @@ class VaultRepositoryTest {
every { isVaultUnlocked(any()) } returns false
every { isVaultUnlocking(any()) } returns false
every { lockVault(any()) } just runs
every { lockVaultIfNecessary(any()) } just runs
every { lockVaultForCurrentUser() } just runs
}
@ -552,13 +551,6 @@ class VaultRepositoryTest {
verify { vaultLockManager.lockVaultForCurrentUser() }
}
@Test
fun `lockVaultIfNecessary should delete to the VaultLockManager`() {
val userId = "userId"
vaultRepository.lockVaultIfNecessary(userId = userId)
verify { vaultLockManager.lockVaultIfNecessary(userId = userId) }
}
@Suppress("MaxLineLength")
@Test
fun `unlockVaultAndSyncForCurrentUser with VaultLockManager Success should unlock for the current user, sync, and return Success`() =

View file

@ -123,20 +123,14 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
}
@Test
fun `on VaultTimeoutTypeSelect should update the selection and emit ShowToast()`() = runTest {
fun `on VaultTimeoutTypeSelect should update the selection()`() = runTest {
val settingsRepository = mockk<SettingsRepository>() {
every { vaultTimeout = any() } just runs
}
val viewModel = createViewModel(settingsRepository = settingsRepository)
viewModel.eventFlow.test {
viewModel.trySendAction(
AccountSecurityAction.VaultTimeoutTypeSelect(VaultTimeout.Type.FOUR_HOURS),
)
assertEquals(
AccountSecurityEvent.ShowToast("Not yet implemented.".asText()),
awaitItem(),
)
}
viewModel.trySendAction(
AccountSecurityAction.VaultTimeoutTypeSelect(VaultTimeout.Type.FOUR_HOURS),
)
assertEquals(
DEFAULT_STATE.copy(
vaultTimeout = VaultTimeout.FourHours,
@ -147,22 +141,16 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
}
@Test
fun `on CustomVaultTimeoutSelect should update the selection and emit ShowToast()`() = runTest {
fun `on CustomVaultTimeoutSelect should update the selection()`() = runTest {
val settingsRepository = mockk<SettingsRepository>() {
every { vaultTimeout = any() } just runs
}
val viewModel = createViewModel(settingsRepository = settingsRepository)
viewModel.eventFlow.test {
viewModel.trySendAction(
AccountSecurityAction.CustomVaultTimeoutSelect(
customVaultTimeout = VaultTimeout.Custom(vaultTimeoutInMinutes = 360),
),
)
assertEquals(
AccountSecurityEvent.ShowToast("Not yet implemented.".asText()),
awaitItem(),
)
}
viewModel.trySendAction(
AccountSecurityAction.CustomVaultTimeoutSelect(
customVaultTimeout = VaultTimeout.Custom(vaultTimeoutInMinutes = 360),
),
)
assertEquals(
DEFAULT_STATE.copy(
vaultTimeout = VaultTimeout.Custom(vaultTimeoutInMinutes = 360),
@ -180,15 +168,9 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
every { vaultTimeoutAction = any() } just runs
}
val viewModel = createViewModel(settingsRepository = settingsRepository)
viewModel.eventFlow.test {
viewModel.trySendAction(
AccountSecurityAction.VaultTimeoutActionSelect(VaultTimeoutAction.LOGOUT),
)
assertEquals(
AccountSecurityEvent.ShowToast("Not yet implemented.".asText()),
awaitItem(),
)
}
viewModel.trySendAction(
AccountSecurityAction.VaultTimeoutActionSelect(VaultTimeoutAction.LOGOUT),
)
assertEquals(
DEFAULT_STATE.copy(
vaultTimeoutAction = VaultTimeoutAction.LOGOUT,