import { mock, MockProxy } from "jest-mock-extended"; import { of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionService } from "@bitwarden/admin-console/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { LogoutReason, UserDecryptionOptions, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; import { Matrix } from "../../../spec/matrix"; import { ApiService } from "../../abstractions/api.service"; import { InternalOrganizationServiceAbstraction } from "../../admin-console/abstractions/organization/organization.service.abstraction"; import { InternalPolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService } from "../../admin-console/abstractions/provider.service"; import { Account, AccountService } from "../../auth/abstractions/account.service"; import { AuthService } from "../../auth/abstractions/auth.service"; import { AvatarService } from "../../auth/abstractions/avatar.service"; import { TokenService } from "../../auth/abstractions/token.service"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { DomainSettingsService } from "../../autofill/services/domain-settings.service"; import { BillingAccountProfileStateService } from "../../billing/abstractions"; import { AccountCryptographicStateService } from "../../key-management/account-cryptography/account-cryptographic-state.service"; import { EncString } from "../../key-management/crypto/models/enc-string"; import { KeyConnectorService } from "../../key-management/key-connector/abstractions/key-connector.service"; import { InternalMasterPasswordServiceAbstraction } from "../../key-management/master-password/abstractions/master-password.service.abstraction"; import { MasterKeyWrappedUserKey, MasterPasswordSalt, MasterPasswordUnlockData, } from "../../key-management/master-password/types/master-password.types"; import { SecurityStateService } from "../../key-management/security-state/abstractions/security-state.service"; import { SendApiService } from "../../tools/send/services/send-api.service.abstraction"; import { InternalSendService } from "../../tools/send/services/send.service.abstraction"; import { UserId } from "../../types/guid"; import { CipherService } from "../../vault/abstractions/cipher.service"; import { FolderApiServiceAbstraction } from "../../vault/abstractions/folder/folder-api.service.abstraction"; import { InternalFolderService } from "../../vault/abstractions/folder/folder.service.abstraction"; import { LogService } from "../abstractions/log.service"; import { MessageSender } from "../messaging"; import { StateProvider } from "../state"; import { DefaultSyncService } from "./default-sync.service"; import { SyncResponse } from "./sync.response"; describe("DefaultSyncService", () => { let masterPasswordAbstraction: MockProxy; let accountService: MockProxy; let apiService: MockProxy; let domainSettingsService: MockProxy; let folderService: MockProxy; let cipherService: MockProxy; let keyService: MockProxy; let collectionService: MockProxy; let messageSender: MockProxy; let policyService: MockProxy; let sendService: MockProxy; let logService: MockProxy; let keyConnectorService: MockProxy; let providerService: MockProxy; let folderApiService: MockProxy; let organizationService: MockProxy; let sendApiService: MockProxy; let userDecryptionOptionsService: MockProxy; let avatarService: MockProxy; let logoutCallback: jest.Mock, [logoutReason: LogoutReason, userId?: UserId]>; let billingAccountProfileStateService: MockProxy; let tokenService: MockProxy; let authService: MockProxy; let stateProvider: MockProxy; let securityStateService: MockProxy; let kdfConfigService: MockProxy; let accountCryptographicStateService: MockProxy; let sut: DefaultSyncService; beforeEach(() => { masterPasswordAbstraction = mock(); accountService = mock(); apiService = mock(); domainSettingsService = mock(); folderService = mock(); cipherService = mock(); keyService = mock(); collectionService = mock(); messageSender = mock(); policyService = mock(); sendService = mock(); logService = mock(); keyConnectorService = mock(); keyConnectorService.convertAccountRequired$ = of(false); providerService = mock(); folderApiService = mock(); organizationService = mock(); sendApiService = mock(); userDecryptionOptionsService = mock(); avatarService = mock(); logoutCallback = jest.fn(); billingAccountProfileStateService = mock(); tokenService = mock(); authService = mock(); stateProvider = mock(); securityStateService = mock(); kdfConfigService = mock(); accountCryptographicStateService = mock(); sut = new DefaultSyncService( masterPasswordAbstraction, accountService, apiService, domainSettingsService, folderService, cipherService, keyService, collectionService, messageSender, policyService, sendService, logService, keyConnectorService, providerService, folderApiService, organizationService, sendApiService, userDecryptionOptionsService, avatarService, logoutCallback, billingAccountProfileStateService, tokenService, authService, stateProvider, securityStateService, kdfConfigService, accountCryptographicStateService, ); }); const user1 = "user1" as UserId; const emptySyncResponse = new SyncResponse({ profile: { id: user1, }, folders: [], collections: [], ciphers: [], sends: [], domains: [], policies: [], }); describe("fullSync", () => { beforeEach(() => { accountService.activeAccount$ = of({ id: user1 } as Account); Matrix.autoMockMethod(authService.authStatusFor$, () => of(AuthenticationStatus.Unlocked)); apiService.getSync.mockResolvedValue(emptySyncResponse); Matrix.autoMockMethod(userDecryptionOptionsService.userDecryptionOptionsById$, () => of({ hasMasterPassword: true } satisfies UserDecryptionOptions), ); stateProvider.getUser.mockReturnValue(mock()); }); it("sets the correct keys for a V1 user with old response model", async () => { const v1Profile = { id: user1, key: "encryptedUserKey", privateKey: "privateKey", providers: [] as any[], organizations: [] as any[], providerOrganizations: [] as any[], avatarColor: "#fff", securityStamp: "stamp", emailVerified: true, verifyDevices: false, premiumPersonally: false, premiumFromOrganization: false, usesKeyConnector: false, }; apiService.getSync.mockResolvedValue( new SyncResponse({ profile: v1Profile, folders: [], collections: [], ciphers: [], sends: [], domains: [], policies: [], }), ); await sut.fullSync(true); expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( new EncString("encryptedUserKey"), user1, ); expect(keyService.setPrivateKey).toHaveBeenCalledWith("privateKey", user1); expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); }); it("sets the correct keys for a V1 user", async () => { const v1Profile = { id: user1, key: "encryptedUserKey", privateKey: "privateKey", providers: [] as any[], organizations: [] as any[], providerOrganizations: [] as any[], avatarColor: "#fff", securityStamp: "stamp", emailVerified: true, verifyDevices: false, premiumPersonally: false, premiumFromOrganization: false, usesKeyConnector: false, accountKeys: { publicKeyEncryptionKeyPair: { wrappedPrivateKey: "wrappedPrivateKey", publicKey: "publicKey", }, }, }; apiService.getSync.mockResolvedValue( new SyncResponse({ profile: v1Profile, folders: [], collections: [], ciphers: [], sends: [], domains: [], policies: [], }), ); await sut.fullSync(true); expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( new EncString("encryptedUserKey"), user1, ); expect(keyService.setPrivateKey).toHaveBeenCalledWith("wrappedPrivateKey", user1); expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); }); it("sets the correct keys for a V2 user", async () => { const v2Profile = { id: user1, key: "encryptedUserKey", providers: [] as unknown[], organizations: [] as unknown[], providerOrganizations: [] as unknown[], avatarColor: "#fff", securityStamp: "stamp", emailVerified: true, verifyDevices: false, premiumPersonally: false, premiumFromOrganization: false, usesKeyConnector: false, privateKey: "wrappedPrivateKey", accountKeys: { publicKeyEncryptionKeyPair: { wrappedPrivateKey: "wrappedPrivateKey", publicKey: "publicKey", signedPublicKey: "signedPublicKey", }, signatureKeyPair: { wrappedSigningKey: "wrappedSigningKey", verifyingKey: "verifyingKey", }, securityState: { securityState: "securityState", }, }, }; apiService.getSync.mockResolvedValue( new SyncResponse({ profile: v2Profile, folders: [], collections: [], ciphers: [], sends: [], domains: [], policies: [], }), ); await sut.fullSync(true); expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( new EncString("encryptedUserKey"), user1, ); expect(keyService.setPrivateKey).toHaveBeenCalledWith("wrappedPrivateKey", user1); expect(keyService.setUserSigningKey).toHaveBeenCalledWith("wrappedSigningKey", user1); expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith( "securityState", user1, ); expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); }); it("does a token refresh when option missing from options", async () => { await sut.fullSync(true, { allowThrowOnError: false }); expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); expect(apiService.getSync).toHaveBeenCalledTimes(1); }); it("does a token refresh when boolean passed in", async () => { await sut.fullSync(true, false); expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); expect(apiService.getSync).toHaveBeenCalledTimes(1); }); it("does a token refresh when skipTokenRefresh option passed in with false and allowThrowOnError also passed in", async () => { await sut.fullSync(true, { allowThrowOnError: false, skipTokenRefresh: false }); expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); expect(apiService.getSync).toHaveBeenCalledTimes(1); }); it("does a token refresh when skipTokenRefresh option passed in with false by itself", async () => { await sut.fullSync(true, { skipTokenRefresh: false }); expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); expect(apiService.getSync).toHaveBeenCalledTimes(1); }); it("does not do a token refresh when skipTokenRefresh passed in as true", async () => { await sut.fullSync(true, { skipTokenRefresh: true }); expect(apiService.refreshIdentityToken).not.toHaveBeenCalled(); expect(apiService.getSync).toHaveBeenCalledTimes(1); }); it("does not do a token refresh when skipTokenRefresh passed in as true and allowThrowOnError also passed in", async () => { await sut.fullSync(true, { allowThrowOnError: false, skipTokenRefresh: true }); expect(apiService.refreshIdentityToken).not.toHaveBeenCalled(); expect(apiService.getSync).toHaveBeenCalledTimes(1); }); it("does a token refresh when nothing passed in", async () => { await sut.fullSync(true); expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); expect(apiService.getSync).toHaveBeenCalledTimes(1); }); describe("in-flight syncs", () => { beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); it("does not call getSync when one is already in progress", async () => { const fullSyncPromises = [sut.fullSync(true), sut.fullSync(false), sut.fullSync(false)]; jest.advanceTimersByTime(100); await Promise.all(fullSyncPromises); expect(apiService.getSync).toHaveBeenCalledTimes(1); }); it("does not call refreshIdentityToken when one is already in progress", async () => { const fullSyncPromises = [sut.fullSync(true), sut.fullSync(false), sut.fullSync(false)]; jest.advanceTimersByTime(100); await Promise.all(fullSyncPromises); expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1); }); it("resets the in-flight properties when the complete", async () => { const fullSyncPromises = [sut.fullSync(true), sut.fullSync(true)]; await Promise.all(fullSyncPromises); expect(sut["inFlightApiCalls"].refreshToken).toBeNull(); expect(sut["inFlightApiCalls"].sync).toBeNull(); }); }); describe("syncUserDecryption", () => { const salt = "test@example.com"; const kdf = new PBKDF2KdfConfig(600_000); const encryptedUserKey = "testUserKey"; it("should set master password unlock when present in user decryption", async () => { const syncResponse = new SyncResponse({ Profile: { Id: user1, }, UserDecryption: { MasterPasswordUnlock: { Salt: salt, Kdf: { KdfType: kdf.kdfType, Iterations: kdf.iterations, }, MasterKeyEncryptedUserKey: encryptedUserKey, }, }, }); apiService.getSync.mockResolvedValue(syncResponse); await sut.fullSync(true, true); expect(masterPasswordAbstraction.setMasterPasswordUnlockData).toHaveBeenCalledWith( new MasterPasswordUnlockData( salt as MasterPasswordSalt, kdf, encryptedUserKey as MasterKeyWrappedUserKey, ), user1, ); }); it("should not set master password unlock when not present in user decryption", async () => { const syncResponse = new SyncResponse({ Profile: { Id: user1, }, UserDecryption: {}, }); apiService.getSync.mockResolvedValue(syncResponse); await sut.fullSync(true, true); expect(masterPasswordAbstraction.setMasterPasswordUnlockData).not.toHaveBeenCalled(); }); }); describe("mutate 'last update time'", () => { let mockUserState: { update: jest.Mock }; const setupMockUserState = () => { const mockUserState = { update: jest.fn() }; jest.spyOn(stateProvider, "getUser").mockReturnValue(mockUserState as any); return mockUserState; }; const setupSyncScenario = (revisionDate: Date, lastSyncDate: Date) => { jest.spyOn(apiService, "getAccountRevisionDate").mockResolvedValue(revisionDate.getTime()); jest.spyOn(sut as any, "getLastSync").mockResolvedValue(lastSyncDate); }; const expectUpdateCallCount = ( mockUserState: { update: jest.Mock }, expectedCount: number, ) => { if (expectedCount === 0) { expect(mockUserState.update).not.toHaveBeenCalled(); } else { expect(mockUserState.update).toHaveBeenCalledTimes(expectedCount); } }; const defaultSyncOptions = { allowThrowOnError: true, skipTokenRefresh: true }; const errorTolerantSyncOptions = { allowThrowOnError: false, skipTokenRefresh: true }; beforeEach(() => { mockUserState = setupMockUserState(); }); it("uses the current time when a sync is forced", async () => { // Mock the value of this observable because it's used in `syncProfile`. Without it, the test breaks. keyConnectorService.convertAccountRequired$ = of(false); jest.useFakeTimers({ now: Date.now() }); await sut.fullSync(true, defaultSyncOptions); expectUpdateCallCount(mockUserState, 1); // Get the first and only call to update(...) const updateCall = mockUserState.update.mock.calls[0]; // Get the first argument to update(...) -- this will be the date callback that returns the date of the last successful sync const dateCallback = updateCall[0]; const actualDate = dateCallback() as Date; expect(actualDate.getTime()).toEqual(jest.now()); jest.useRealTimers(); }); it("updates last sync time when no sync is necessary", async () => { const revisionDate = new Date(1); setupSyncScenario(revisionDate, revisionDate); const syncResult = await sut.fullSync(false, defaultSyncOptions); // Sync should complete but return false since no sync was needed expect(syncResult).toBe(false); expectUpdateCallCount(mockUserState, 1); }); it("updates last sync time when sync is successful", async () => { setupSyncScenario(new Date(2), new Date(1)); const syncResult = await sut.fullSync(false, defaultSyncOptions); expect(syncResult).toBe(true); expectUpdateCallCount(mockUserState, 1); }); describe("error scenarios", () => { it("does not update last sync time when sync fails", async () => { apiService.getSync.mockRejectedValue(new Error("not connected")); const syncResult = await sut.fullSync(true, errorTolerantSyncOptions); expect(syncResult).toBe(false); expectUpdateCallCount(mockUserState, 0); }); it("does not update last sync time when account revision check fails", async () => { jest .spyOn(apiService, "getAccountRevisionDate") .mockRejectedValue(new Error("not connected")); const syncResult = await sut.fullSync(false, errorTolerantSyncOptions); expect(syncResult).toBe(false); expectUpdateCallCount(mockUserState, 0); }); }); }); }); });