Skip to content

Commit

Permalink
Vault Timeout Service Tests & Bug Fix (#7021)
Browse files Browse the repository at this point in the history
* Add Tests to VaultTimeoutService

* Fix Bug

Get availableTimeoutActions for the user actually locking
instead of getting the timeout actions for the active user.
  • Loading branch information
justindbaur committed Nov 29, 2023
1 parent 48c6033 commit 3451ee8
Show file tree
Hide file tree
Showing 2 changed files with 314 additions and 1 deletion.
313 changes: 313 additions & 0 deletions libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<CipherService>;
let folderService: MockProxy<FolderService>;
let collectionService: MockProxy<CollectionService>;
let cryptoService: MockProxy<CryptoService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let messagingService: MockProxy<MessagingService>;
let searchService: MockProxy<SearchService>;
let stateService: MockProxy<StateService>;
let authService: MockProxy<AuthService>;
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
let loggedOutCallback: jest.Mock<Promise<void>, [expired: boolean, userId?: string]>;

let accountsSubject: BehaviorSubject<Record<string, Account>>;
let vaultTimeoutActionSubject: BehaviorSubject<VaultTimeoutAction>;
let availableVaultTimeoutActionsSubject: BehaviorSubject<VaultTimeoutAction[]>;

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<VaultTimeoutAction[]>([]);

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<VaultTimeoutAction>(accounts[userId]?.timeoutAction);
});

vaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation((userId) => {
return new BehaviorSubject<VaultTimeoutAction[]>(
// Default to both options if it wasn't supplied at all
accounts[userId]?.availableTimeoutActions ?? [
VaultTimeoutAction.Lock,
VaultTimeoutAction.LogOut,
]
);
});

const accountsSubjectValue: Record<string, Account> = Object.keys(accounts).reduce(
(agg, key) => {
const newPartial: Record<string, unknown> = {};
newPartial[key] = null; // No values actually matter on this other than the key
return Object.assign(agg, newPartial);
},
{} as Record<string, Account>
);
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");
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down

0 comments on commit 3451ee8

Please sign in to comment.