Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vault Timeout Service Tests & Bug Fix #7021

Merged
merged 2 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
justindbaur marked this conversation as resolved.
Show resolved Hide resolved
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 () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I prefer single assert tests, which would make this test a describe block with four tests in it

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)
justindbaur marked this conversation as resolved.
Show resolved Hide resolved
);
const supportsLock = availableActions.includes(VaultTimeoutAction.Lock);
if (!supportsLock) {
Expand Down
Loading