diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index f004bfb9e5..e28e0a4689 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManager.kt index 40c09d0748..4c85589cd4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManager.kt @@ -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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt index 5258c12542..b2a9cf8520 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt @@ -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 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, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt index c5e2d183cf..6e350aef66 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt @@ -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, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt index f4a9cf58aa..22d7bc918f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt @@ -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() { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 4fdbda1e68..291819b976 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -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]. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 2558b9eab1..450c8b367f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -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() } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/FakeAppForegroundManager.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/FakeAppForegroundManager.kt new file mode 100644 index 0000000000..2bd8e637aa --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/FakeAppForegroundManager.kt @@ -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 + get() = mutableAppForegroundStateFlow.asStateFlow() + + /** + * The current [AppForegroundState] tracked by the [appForegroundStateFlow]. + */ + var appForegroundState: AppForegroundState + get() = mutableAppForegroundStateFlow.value + set(value) { + mutableAppForegroundStateFlow.value = value + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt index e0a7c09468..8d2f3fefe7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt @@ -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.ThirtyMinutes) + private val mutableVaultTimeoutActionStateFlow = + MutableStateFlow(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( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index 04f2435be6..3dccb7cb69 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -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`() = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt index 60ec61794a..bd771c521c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt @@ -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() { 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() { 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,