diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts new file mode 100644 index 000000000000..8d4e8c8d416e --- /dev/null +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts @@ -0,0 +1,313 @@ +import { MockProxy, any, mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { SearchService } from "../../abstractions/search.service"; +import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; +import { AuthService } from "../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { Account } from "../../platform/models/domain/account"; +import { CipherService } from "../../vault/abstractions/cipher.service"; +import { CollectionService } from "../../vault/abstractions/collection.service"; +import { FolderService } from "../../vault/abstractions/folder/folder.service.abstraction"; + +import { VaultTimeoutService } from "./vault-timeout.service"; + +describe("VaultTimeoutService", () => { + let cipherService: MockProxy; + let folderService: MockProxy; + let collectionService: MockProxy; + let cryptoService: MockProxy; + let platformUtilsService: MockProxy; + let messagingService: MockProxy; + let searchService: MockProxy; + let stateService: MockProxy; + let authService: MockProxy; + let vaultTimeoutSettingsService: MockProxy; + let lockedCallback: jest.Mock, [userId: string]>; + let loggedOutCallback: jest.Mock, [expired: boolean, userId?: string]>; + + let accountsSubject: BehaviorSubject>; + let vaultTimeoutActionSubject: BehaviorSubject; + let availableVaultTimeoutActionsSubject: BehaviorSubject; + + let vaultTimeoutService: VaultTimeoutService; + + beforeEach(() => { + cipherService = mock(); + folderService = mock(); + collectionService = mock(); + cryptoService = mock(); + platformUtilsService = mock(); + messagingService = mock(); + searchService = mock(); + stateService = mock(); + authService = mock(); + vaultTimeoutSettingsService = mock(); + + lockedCallback = jest.fn(); + loggedOutCallback = jest.fn(); + + accountsSubject = new BehaviorSubject(null); + + stateService.accounts$ = accountsSubject; + + vaultTimeoutActionSubject = new BehaviorSubject(VaultTimeoutAction.Lock); + + vaultTimeoutSettingsService.vaultTimeoutAction$.mockReturnValue(vaultTimeoutActionSubject); + + availableVaultTimeoutActionsSubject = new BehaviorSubject([]); + + vaultTimeoutService = new VaultTimeoutService( + cipherService, + folderService, + collectionService, + cryptoService, + platformUtilsService, + messagingService, + searchService, + stateService, + authService, + vaultTimeoutSettingsService, + lockedCallback, + loggedOutCallback + ); + }); + + // Helper for setting up mocks for multiple users + const setupAccounts = ( + accounts: Record< + string, + { + authStatus?: AuthenticationStatus; + isAuthenticated?: boolean; + lastActive?: number; + vaultTimeout?: number; + timeoutAction?: VaultTimeoutAction; + availableTimeoutActions?: VaultTimeoutAction[]; + } + >, + userId?: string + ) => { + // Both are available by default and the specific test can change this per test + availableVaultTimeoutActionsSubject.next([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]); + + authService.getAuthStatus.mockImplementation((userId) => { + return Promise.resolve(accounts[userId]?.authStatus); + }); + stateService.getIsAuthenticated.mockImplementation((options) => { + return Promise.resolve(accounts[options.userId]?.isAuthenticated); + }); + + vaultTimeoutSettingsService.getVaultTimeout.mockImplementation((userId) => { + return Promise.resolve(accounts[userId]?.vaultTimeout); + }); + + stateService.getLastActive.mockImplementation((options) => { + return Promise.resolve(accounts[options.userId]?.lastActive); + }); + + stateService.getUserId.mockResolvedValue(userId); + + vaultTimeoutSettingsService.vaultTimeoutAction$.mockImplementation((userId) => { + return new BehaviorSubject(accounts[userId]?.timeoutAction); + }); + + vaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation((userId) => { + return new BehaviorSubject( + // Default to both options if it wasn't supplied at all + accounts[userId]?.availableTimeoutActions ?? [ + VaultTimeoutAction.Lock, + VaultTimeoutAction.LogOut, + ] + ); + }); + + const accountsSubjectValue: Record = Object.keys(accounts).reduce( + (agg, key) => { + const newPartial: Record = {}; + newPartial[key] = null; // No values actually matter on this other than the key + return Object.assign(agg, newPartial); + }, + {} as Record + ); + accountsSubject.next(accountsSubjectValue); + }; + + const expectUserToHaveLocked = (userId: string) => { + // This does NOT assert all the things that the lock process does + expect(stateService.getIsAuthenticated).toHaveBeenCalledWith({ userId: userId }); + expect(vaultTimeoutSettingsService.availableVaultTimeoutActions$).toHaveBeenCalledWith(userId); + expect(stateService.setEverBeenUnlocked).toHaveBeenCalledWith(true, { userId: userId }); + expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, { userId: userId }); + expect(cryptoService.clearUserKey).toHaveBeenCalledWith(false, userId); + expect(cryptoService.clearMasterKey).toHaveBeenCalledWith(userId); + expect(cipherService.clearCache).toHaveBeenCalledWith(userId); + expect(lockedCallback).toHaveBeenCalledWith(userId); + }; + + const expectUserToHaveLoggedOut = (userId: string) => { + expect(loggedOutCallback).toHaveBeenCalledWith(false, userId); + }; + + const expectNoAction = (userId: string) => { + expect(lockedCallback).not.toHaveBeenCalledWith(userId); + expect(loggedOutCallback).not.toHaveBeenCalledWith(any(), userId); + }; + + describe("checkVaultTimeout", () => { + it.each([AuthenticationStatus.Locked, AuthenticationStatus.LoggedOut])( + "should not try to log out or lock any user that has authStatus === %s.", + async (authStatus) => { + platformUtilsService.isViewOpen.mockResolvedValue(false); + setupAccounts({ + 1: { + authStatus: authStatus, + isAuthenticated: true, + }, + }); + + expectNoAction("1"); + } + ); + + it.each([ + null, // never + -1, // onRestart + -2, // onLocked + -3, // onSleep + -4, // onIdle + ])( + "does not log out or lock a user who has %s as their vault timeout", + async (vaultTimeout) => { + setupAccounts({ + 1: { + authStatus: AuthenticationStatus.Unlocked, + vaultTimeout: vaultTimeout, + isAuthenticated: true, + }, + }); + + await vaultTimeoutService.checkVaultTimeout(); + + expectNoAction("1"); + } + ); + + it.each([undefined, null])( + "should not log out or lock a user who has %s lastActive value", + async (lastActive) => { + setupAccounts({ + 1: { + authStatus: AuthenticationStatus.Unlocked, + vaultTimeout: 1, // One minute + lastActive: lastActive, + }, + }); + + await vaultTimeoutService.checkVaultTimeout(); + + expectNoAction("1"); + } + ); + + it("should lock an account that isn't active and has immediate as their timeout when view is not open", async () => { + // Arrange + platformUtilsService.isViewOpen.mockResolvedValue(false); + + setupAccounts({ + 1: { + authStatus: AuthenticationStatus.Unlocked, + isAuthenticated: true, + vaultTimeout: 0, // Immediately + lastActive: new Date().getTime() - 10 * 1000, // Last active 10 seconds ago + }, + 2: { + authStatus: AuthenticationStatus.Unlocked, + isAuthenticated: true, + vaultTimeout: 1, // One minute + lastActive: new Date().getTime() - 10 * 1000, // Last active 10 seconds ago + }, + }); + + // Act + await vaultTimeoutService.checkVaultTimeout(); + + // Assert + expectUserToHaveLocked("1"); + expectNoAction("2"); + }); + + it("should run action on an account that hasn't been active for greater than 1 minute and has a vault timeout for 1 minutes", async () => { + platformUtilsService.isViewOpen.mockResolvedValue(false); + + setupAccounts( + { + 1: { + authStatus: AuthenticationStatus.Unlocked, + isAuthenticated: true, + vaultTimeout: 1, // One minute + lastActive: new Date().getTime() - 10 * 1000, + }, + 2: { + authStatus: AuthenticationStatus.Unlocked, + isAuthenticated: true, + vaultTimeout: 1, // One minute + lastActive: new Date().getTime() - 61 * 1000, // Last active 61 seconds ago + }, + 3: { + authStatus: AuthenticationStatus.Unlocked, + isAuthenticated: true, + vaultTimeout: 1, // One minute + lastActive: new Date().getTime() - 120 * 1000, // Last active 2 minutes ago + timeoutAction: VaultTimeoutAction.LogOut, + availableTimeoutActions: [VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut], + }, + 4: { + authStatus: AuthenticationStatus.Unlocked, + isAuthenticated: true, + vaultTimeout: 1, // One minute + lastActive: new Date().getTime() - 100 * 1000, // Last active 100 seconds ago + timeoutAction: VaultTimeoutAction.Lock, + availableTimeoutActions: [VaultTimeoutAction.LogOut], + }, + }, + "2" // Treat user 2 as the active user + ); + + await vaultTimeoutService.checkVaultTimeout(); + + expectNoAction("1"); + expectUserToHaveLocked("2"); + + // Active users should have additional steps ran + expect(searchService.clearIndex).toHaveBeenCalled(); + expect(folderService.clearCache).toHaveBeenCalled(); + + expectUserToHaveLoggedOut("3"); // They have chosen logout as their action and it's available, log them out + expectUserToHaveLoggedOut("4"); // They may have had lock as their chosen action but it's not available to them so logout + }); + + it("should not lock any accounts as long as a view is known to be open, no matter if they haven't been active since before their timeout", async () => { + platformUtilsService.isViewOpen.mockResolvedValue(true); + + setupAccounts({ + 1: { + // Neither of these setup values ever get called + authStatus: AuthenticationStatus.Unlocked, + isAuthenticated: true, + lastActive: new Date().getTime() - 80 * 1000, // Last active 80 seconds ago + vaultTimeout: 1, // Vault timeout of 1 minute ago + }, + }); + + await vaultTimeoutService.checkVaultTimeout(); + + expectNoAction("1"); + }); + }); +}); diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.ts index 9e5a78834f7e..2ce1416fcdec 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout.service.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.ts @@ -71,7 +71,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { } const availableActions = await firstValueFrom( - this.vaultTimeoutSettingsService.availableVaultTimeoutActions$() + this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId) ); const supportsLock = availableActions.includes(VaultTimeoutAction.Lock); if (!supportsLock) {