From cc28149e60e86268fadc73bb90ee16d75838c3c8 Mon Sep 17 00:00:00 2001 From: Tom <144813356+ttalty@users.noreply.github.com> Date: Mon, 18 Mar 2024 14:36:43 -0400 Subject: [PATCH] [PM-5572] Event upload and collection state provider migration (#7863) * event upload and collection state provider migration * cipher can be null when exporting org * Addressing pr comments. Casting UserId from calling methods * fixing userAuth observable in event collection service * Adding more documentation for the changes. * cli needed state provider and account services added * Addressing pr comments on modifying should update * No need to auth on event upload * Simplifying the takeEvents for pulling user events * Reverting shouldUpdate to previous state * Removing redundant comment * Removing account service for event upload * Modifying the shouldUpdate to evaluate the logic outside of the observable * Adding back in the auth for event upload service and adding event upload to the cli logout method * Adding the browser service factories * Updating the browser services away from get background * Removing event collect and upload services from browser services * Removing the audit service import * Adding the event collection migration and migration test * Event collection state needs to be stored on disk * removing event collection from state service and abstraction * removing event collection from the account data * Saving the migrations themselves --- .../browser/src/background/main.background.ts | 8 +- .../event-collection-service.factory.ts | 10 +- .../event-upload-service.factory.ts | 10 +- .../src/popup/services/services.module.ts | 12 -- apps/cli/src/bw.ts | 7 +- apps/desktop/src/app/app.component.ts | 3 +- .../src/services/jslib-services.module.ts | 5 +- .../event/event-upload.service.ts | 4 +- libs/common/src/models/data/event.data.ts | 6 + .../platform/abstractions/state.service.ts | 3 - .../src/platform/models/domain/account.ts | 2 - .../src/platform/services/state.service.ts | 19 -- .../src/platform/state/state-definitions.ts | 1 + .../event/event-collection.service.ts | 102 ++++++++--- .../services/event/event-upload.service.ts | 53 ++++-- .../src/services/event/key-definitions.ts | 10 ++ libs/common/src/state-migrations/migrate.ts | 6 +- ...event-collection-to-state-provider.spec.ts | 168 ++++++++++++++++++ ...move-event-collection-to-state-provider.ts | 49 +++++ 19 files changed, 381 insertions(+), 97 deletions(-) create mode 100644 libs/common/src/services/event/key-definitions.ts create mode 100644 libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 4979ee6838..b0f65ec8ce 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -727,14 +727,16 @@ export default class MainBackground { ); this.eventUploadService = new EventUploadService( this.apiService, - this.stateService, + this.stateProvider, this.logService, + this.accountService, ); this.eventCollectionService = new EventCollectionService( this.cipherService, - this.stateService, + this.stateProvider, this.organizationService, this.eventUploadService, + this.accountService, ); this.totpService = new TotpService(this.cryptoFunctionService, this.logService); @@ -1108,7 +1110,7 @@ export default class MainBackground { async logout(expired: boolean, userId?: UserId) { userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; - await this.eventUploadService.uploadEvents(userId); + await this.eventUploadService.uploadEvents(userId as UserId); await Promise.all([ this.syncService.setLastSync(new Date(0), userId), diff --git a/apps/browser/src/background/service-factories/event-collection-service.factory.ts b/apps/browser/src/background/service-factories/event-collection-service.factory.ts index 7ce77da045..ec892c73dd 100644 --- a/apps/browser/src/background/service-factories/event-collection-service.factory.ts +++ b/apps/browser/src/background/service-factories/event-collection-service.factory.ts @@ -5,15 +5,14 @@ import { organizationServiceFactory, OrganizationServiceInitOptions, } from "../../admin-console/background/service-factories/organization-service.factory"; +import { accountServiceFactory } from "../../auth/background/service-factories/account-service.factory"; import { FactoryOptions, CachedServices, factory, } from "../../platform/background/service-factories/factory-options"; -import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../platform/background/service-factories/state-service.factory"; +import { stateProviderFactory } from "../../platform/background/service-factories/state-provider.factory"; +import { StateServiceInitOptions } from "../../platform/background/service-factories/state-service.factory"; import { cipherServiceFactory, CipherServiceInitOptions, @@ -43,9 +42,10 @@ export function eventCollectionServiceFactory( async () => new EventCollectionService( await cipherServiceFactory(cache, opts), - await stateServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), await organizationServiceFactory(cache, opts), await eventUploadServiceFactory(cache, opts), + await accountServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/background/service-factories/event-upload-service.factory.ts b/apps/browser/src/background/service-factories/event-upload-service.factory.ts index fcaec459c0..4e1d7949be 100644 --- a/apps/browser/src/background/service-factories/event-upload-service.factory.ts +++ b/apps/browser/src/background/service-factories/event-upload-service.factory.ts @@ -1,6 +1,7 @@ import { EventUploadService as AbstractEventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; +import { accountServiceFactory } from "../../auth/background/service-factories/account-service.factory"; import { ApiServiceInitOptions, apiServiceFactory, @@ -14,10 +15,8 @@ import { logServiceFactory, LogServiceInitOptions, } from "../../platform/background/service-factories/log-service.factory"; -import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../platform/background/service-factories/state-service.factory"; +import { stateProviderFactory } from "../../platform/background/service-factories/state-provider.factory"; +import { StateServiceInitOptions } from "../../platform/background/service-factories/state-service.factory"; type EventUploadServiceOptions = FactoryOptions; @@ -37,8 +36,9 @@ export function eventUploadServiceFactory( async () => new EventUploadService( await apiServiceFactory(cache, opts), - await stateServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), await logServiceFactory(cache, opts), + await accountServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 7afd9df41e..3fa03804ba 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -17,8 +17,6 @@ import { LoginStrategyServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; @@ -263,16 +261,6 @@ function getBgService(service: keyof MainBackground) { useFactory: getBgService("devicesService"), deps: [], }, - { - provide: EventUploadService, - useFactory: getBgService("eventUploadService"), - deps: [], - }, - { - provide: EventCollectionService, - useFactory: getBgService("eventCollectionService"), - deps: [], - }, { provide: PlatformUtilsService, useExisting: ForegroundPlatformUtilsService, diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index e2110006e8..7435020af0 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -641,15 +641,17 @@ export class Main { this.eventUploadService = new EventUploadService( this.apiService, - this.stateService, + this.stateProvider, this.logService, + this.accountService, ); this.eventCollectionService = new EventCollectionService( this.cipherService, - this.stateService, + this.stateProvider, this.organizationService, this.eventUploadService, + this.accountService, ); } @@ -673,6 +675,7 @@ export class Main { }); const userId = await this.stateService.getUserId(); await Promise.all([ + this.eventUploadService.uploadEvents(userId as UserId), this.syncService.setLastSync(new Date(0)), this.cryptoService.clearKeys(), this.cipherService.clear(userId), diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 574dce9390..c674915b32 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -576,7 +576,8 @@ export class AppComponent implements OnInit, OnDestroy { let preLogoutActiveUserId; try { - await this.eventUploadService.uploadEvents(userBeingLoggedOut); + // Provide the userId of the user to upload events for + await this.eventUploadService.uploadEvents(userBeingLoggedOut as UserId); await this.syncService.setLastSync(new Date(0), userBeingLoggedOut); await this.cryptoService.clearKeys(userBeingLoggedOut); await this.cipherService.clear(userBeingLoggedOut); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index cc4af9b207..d8dcf08835 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -721,16 +721,17 @@ const typesafeProviders: Array = [ safeProvider({ provide: EventUploadServiceAbstraction, useClass: EventUploadService, - deps: [ApiServiceAbstraction, StateServiceAbstraction, LogService], + deps: [ApiServiceAbstraction, StateProvider, LogService, AccountServiceAbstraction], }), safeProvider({ provide: EventCollectionServiceAbstraction, useClass: EventCollectionService, deps: [ CipherServiceAbstraction, - StateServiceAbstraction, + StateProvider, OrganizationServiceAbstraction, EventUploadServiceAbstraction, + AccountServiceAbstraction, ], }), safeProvider({ diff --git a/libs/common/src/abstractions/event/event-upload.service.ts b/libs/common/src/abstractions/event/event-upload.service.ts index b68f6f718d..5b7a98629a 100644 --- a/libs/common/src/abstractions/event/event-upload.service.ts +++ b/libs/common/src/abstractions/event/event-upload.service.ts @@ -1,3 +1,5 @@ +import { UserId } from "../../types/guid"; + export abstract class EventUploadService { - uploadEvents: (userId?: string) => Promise; + uploadEvents: (userId?: UserId) => Promise; } diff --git a/libs/common/src/models/data/event.data.ts b/libs/common/src/models/data/event.data.ts index 2281f0258f..e261e5fd3a 100644 --- a/libs/common/src/models/data/event.data.ts +++ b/libs/common/src/models/data/event.data.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { EventType } from "../../enums"; export class EventData { @@ -5,4 +7,8 @@ export class EventData { cipherId: string; date: string; organizationId: string; + + static fromJSON(obj: Jsonify): EventData { + return Object.assign(new EventData(), obj); + } } diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 68b6260d48..4c7e38f8e8 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -4,7 +4,6 @@ import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-re import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { BiometricKey } from "../../auth/types/biometric-key"; -import { EventData } from "../../models/data/event.data"; import { WindowState } from "../../models/domain/window-state"; import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; @@ -253,8 +252,6 @@ export abstract class StateService { * @deprecated Do not call this directly, use SendService */ setEncryptedSends: (value: { [id: string]: SendData }, options?: StorageOptions) => Promise; - getEventCollection: (options?: StorageOptions) => Promise; - setEventCollection: (value: EventData[], options?: StorageOptions) => Promise; getEverBeenUnlocked: (options?: StorageOptions) => Promise; setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise; getForceSetPasswordReason: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 64232ec615..07efb505a5 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -5,7 +5,6 @@ import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-pa import { KeyConnectorUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/key-connector-user-decryption-option"; import { TrustedDeviceUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/trusted-device-user-decryption-option"; import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response"; -import { EventData } from "../../../models/data/event.data"; import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { GeneratorOptions } from "../../../tools/generator/generator-options"; import { @@ -89,7 +88,6 @@ export class AccountData { GeneratedPasswordHistory[] > = new EncryptionPair(); addEditCipherInfo?: AddEditCipherInfo; - eventCollection?: EventData[]; static fromJSON(obj: DeepJsonify): AccountData { if (obj == null) { diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 73a56fda31..6694b6ab3f 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -8,7 +8,6 @@ import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-re import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { BiometricKey } from "../../auth/types/biometric-key"; -import { EventData } from "../../models/data/event.data"; import { WindowState } from "../../models/domain/window-state"; import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; @@ -1176,24 +1175,6 @@ export class StateService< ); } - @withPrototypeForArrayMembers(EventData) - async getEventCollection(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.data?.eventCollection; - } - - async setEventCollection(value: EventData[], options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.data.eventCollection = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getEverBeenUnlocked(options?: StorageOptions): Promise { return ( (await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))) diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 6a41b82dcc..8115555b2e 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -81,6 +81,7 @@ export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", { export const GENERATOR_DISK = new StateDefinition("generator", "disk"); export const GENERATOR_MEMORY = new StateDefinition("generator", "memory"); +export const EVENT_COLLECTION_DISK = new StateDefinition("eventCollection", "disk"); // Vault diff --git a/libs/common/src/services/event/event-collection.service.ts b/libs/common/src/services/event/event-collection.service.ts index f2447ba24e..2d2b553062 100644 --- a/libs/common/src/services/event/event-collection.service.ts +++ b/libs/common/src/services/event/event-collection.service.ts @@ -1,61 +1,105 @@ +import { firstValueFrom, map, from, zip } from "rxjs"; + import { EventCollectionService as EventCollectionServiceAbstraction } from "../../abstractions/event/event-collection.service"; import { EventUploadService } from "../../abstractions/event/event-upload.service"; import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "../../auth/abstractions/account.service"; +import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { EventType } from "../../enums"; import { EventData } from "../../models/data/event.data"; -import { StateService } from "../../platform/abstractions/state.service"; +import { StateProvider } from "../../platform/state"; import { CipherService } from "../../vault/abstractions/cipher.service"; +import { EVENT_COLLECTION } from "./key-definitions"; + export class EventCollectionService implements EventCollectionServiceAbstraction { constructor( private cipherService: CipherService, - private stateService: StateService, + private stateProvider: StateProvider, private organizationService: OrganizationService, private eventUploadService: EventUploadService, + private accountService: AccountService, ) {} + /** Adds an event to the active user's event collection + * @param eventType the event type to be added + * @param cipherId if provided the id of the cipher involved in the event + * @param uploadImmediately in some cases the recorded events should be uploaded right after being added + * @param organizationId the organizationId involved in the event. If the cipherId is not provided an organizationId is required + */ async collect( eventType: EventType, cipherId: string = null, uploadImmediately = false, organizationId: string = null, ): Promise { - const authed = await this.stateService.getIsAuthenticated(); - if (!authed) { + const userId = await firstValueFrom(this.stateProvider.activeUserId$); + const eventStore = this.stateProvider.getUser(userId, EVENT_COLLECTION); + + if (!(await this.shouldUpdate(cipherId, organizationId))) { return; } - const organizations = await this.organizationService.getAll(); - if (organizations == null) { - return; - } - const orgIds = new Set(organizations.filter((o) => o.useEvents).map((o) => o.id)); - if (orgIds.size === 0) { - return; - } - if (cipherId != null) { - const cipher = await this.cipherService.get(cipherId); - if (cipher == null || cipher.organizationId == null || !orgIds.has(cipher.organizationId)) { - return; - } - } - if (organizationId != null) { - if (!orgIds.has(organizationId)) { - return; - } - } - let eventCollection = await this.stateService.getEventCollection(); - if (eventCollection == null) { - eventCollection = []; - } + const event = new EventData(); event.type = eventType; event.cipherId = cipherId; event.date = new Date().toISOString(); event.organizationId = organizationId; - eventCollection.push(event); - await this.stateService.setEventCollection(eventCollection); + + await eventStore.update((events) => { + events = events ?? []; + events.push(event); + return events; + }); + if (uploadImmediately) { await this.eventUploadService.uploadEvents(); } } + + /** Verifies if the event collection should be updated for the provided information + * @param cipherId the cipher for the event + * @param organizationId the organization for the event + */ + private async shouldUpdate( + cipherId: string = null, + organizationId: string = null, + ): Promise { + const orgIds$ = this.organizationService.organizations$.pipe( + map((orgs) => orgs?.filter((o) => o.useEvents)?.map((x) => x.id) ?? []), + ); + + const cipher$ = from(this.cipherService.get(cipherId)); + + const [accountInfo, orgIds, cipher] = await firstValueFrom( + zip(this.accountService.activeAccount$, orgIds$, cipher$), + ); + + // The user must be authorized + if (accountInfo.status != AuthenticationStatus.Unlocked) { + return false; + } + + // User must have organizations assigned to them + if (orgIds == null || orgIds.length == 0) { + return false; + } + + // If the cipher is null there must be an organization id provided + if (cipher == null && organizationId == null) { + return false; + } + + // If the cipher is present it must be in the user's org list + if (cipher != null && !orgIds.includes(cipher?.organizationId)) { + return false; + } + + // If the organization id is provided it must be in the user's org list + if (organizationId != null && !orgIds.includes(organizationId)) { + return false; + } + + return true; + } } diff --git a/libs/common/src/services/event/event-upload.service.ts b/libs/common/src/services/event/event-upload.service.ts index 75c7a7a193..4ee4300c39 100644 --- a/libs/common/src/services/event/event-upload.service.ts +++ b/libs/common/src/services/event/event-upload.service.ts @@ -1,15 +1,24 @@ +import { firstValueFrom, map } from "rxjs"; + import { ApiService } from "../../abstractions/api.service"; import { EventUploadService as EventUploadServiceAbstraction } from "../../abstractions/event/event-upload.service"; +import { AccountService } from "../../auth/abstractions/account.service"; +import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { EventData } from "../../models/data/event.data"; import { EventRequest } from "../../models/request/event.request"; import { LogService } from "../../platform/abstractions/log.service"; -import { StateService } from "../../platform/abstractions/state.service"; +import { StateProvider } from "../../platform/state"; +import { UserId } from "../../types/guid"; + +import { EVENT_COLLECTION } from "./key-definitions"; export class EventUploadService implements EventUploadServiceAbstraction { private inited = false; constructor( private apiService: ApiService, - private stateService: StateService, + private stateProvider: StateProvider, private logService: LogService, + private accountService: AccountService, ) {} init(checkOnInterval: boolean) { @@ -26,12 +35,26 @@ export class EventUploadService implements EventUploadServiceAbstraction { } } - async uploadEvents(userId?: string): Promise { - const authed = await this.stateService.getIsAuthenticated({ userId: userId }); - if (!authed) { + /** Upload the event collection from state. + * @param userId upload events for provided user. If not active user will be used. + */ + async uploadEvents(userId?: UserId): Promise { + if (!userId) { + userId = await firstValueFrom(this.stateProvider.activeUserId$); + } + + // Get the auth status from the provided user or the active user + const userAuth$ = this.accountService.accounts$.pipe( + map((accounts) => accounts[userId]?.status === AuthenticationStatus.Unlocked), + ); + + const isAuthenticated = await firstValueFrom(userAuth$); + if (!isAuthenticated) { return; } - const eventCollection = await this.stateService.getEventCollection({ userId: userId }); + + const eventCollection = await this.takeEvents(userId); + if (eventCollection == null || eventCollection.length === 0) { return; } @@ -45,15 +68,23 @@ export class EventUploadService implements EventUploadServiceAbstraction { }); try { await this.apiService.postEventsCollect(request); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.clearEvents(userId); } catch (e) { this.logService.error(e); + // Add the events back to state if there was an error and they were not uploaded. + await this.stateProvider.setUserState(EVENT_COLLECTION, eventCollection, userId); } } - private async clearEvents(userId?: string): Promise { - await this.stateService.setEventCollection(null, { userId: userId }); + /** Return user's events and then clear them from state + * @param userId the user to grab and clear events for + */ + private async takeEvents(userId: UserId): Promise { + let taken = null; + await this.stateProvider.getUser(userId, EVENT_COLLECTION).update((current) => { + taken = current ?? []; + return []; + }); + + return taken; } } diff --git a/libs/common/src/services/event/key-definitions.ts b/libs/common/src/services/event/key-definitions.ts new file mode 100644 index 0000000000..1059d24b72 --- /dev/null +++ b/libs/common/src/services/event/key-definitions.ts @@ -0,0 +1,10 @@ +import { EventData } from "../../models/data/event.data"; +import { KeyDefinition, EVENT_COLLECTION_DISK } from "../../platform/state"; + +export const EVENT_COLLECTION: KeyDefinition = KeyDefinition.array( + EVENT_COLLECTION_DISK, + "events", + { + deserializer: (s) => EventData.fromJSON(s), + }, +); diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index e0b17d0694..3bb947d0bd 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -36,6 +36,7 @@ import { TokenServiceStateProviderMigrator } from "./migrations/38-migrate-token import { MoveBillingAccountProfileMigrator } from "./migrations/39-move-billing-account-profile-to-state-providers"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; import { OrganizationMigrator } from "./migrations/40-move-organization-state-to-state-provider"; +import { EventCollectionMigrator } from "./migrations/41-move-event-collection-to-state-provider"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; @@ -44,7 +45,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 40; +export const CURRENT_VERSION = 41; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -86,7 +87,8 @@ export function createMigrationBuilder() { .with(AvatarColorMigrator, 36, 37) .with(TokenServiceStateProviderMigrator, 37, 38) .with(MoveBillingAccountProfileMigrator, 38, 39) - .with(OrganizationMigrator, 39, CURRENT_VERSION); + .with(OrganizationMigrator, 39, 40) + .with(EventCollectionMigrator, 40, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts new file mode 100644 index 0000000000..d88100ebe2 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.spec.ts @@ -0,0 +1,168 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { EventCollectionMigrator } from "./41-move-event-collection-to-state-provider"; + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2"], + "user-1": { + data: { + eventCollection: [ + { + type: 1107, + cipherId: "5154f91d-c469-4d23-aefa-b12a0140d684", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T21:59:50.169Z", + }, + { + type: 1107, + cipherId: "ed4661bd-412c-4b05-89a2-b12a01697a2c", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T22:02:06.089Z", + }, + ], + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + data: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +function rollbackJSON() { + return { + "user_user-1_eventCollection_eventCollection": [ + { + type: 1107, + cipherId: "5154f91d-c469-4d23-aefa-b12a0140d684", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T21:59:50.169Z", + }, + { + type: 1107, + cipherId: "ed4661bd-412c-4b05-89a2-b12a01697a2c", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T22:02:06.089Z", + }, + ], + "user_user-2_eventCollection_data": null as any, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user-1", "user-2"], + "user-1": { + data: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "user-2": { + data: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +describe("EventCollectionMigrator", () => { + let helper: MockProxy; + let sut: EventCollectionMigrator; + const keyDefinitionLike = { + stateDefinition: { + name: "eventCollection", + }, + key: "eventCollection", + }; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 40); + sut = new EventCollectionMigrator(40, 41); + }); + + it("should remove event collections from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("user-1", { + data: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("should set event collections for each account", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("user-1", keyDefinitionLike, [ + { + type: 1107, + cipherId: "5154f91d-c469-4d23-aefa-b12a0140d684", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T21:59:50.169Z", + }, + { + type: 1107, + cipherId: "ed4661bd-412c-4b05-89a2-b12a01697a2c", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T22:02:06.089Z", + }, + ]); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 41); + sut = new EventCollectionMigrator(40, 41); + }); + + it.each(["user-1", "user-2"])("should null out new values", async (userId) => { + await sut.rollback(helper); + expect(helper.setToUser).toHaveBeenCalledWith(userId, keyDefinitionLike, null); + }); + + it("should add event collection values back to accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalled(); + expect(helper.set).toHaveBeenCalledWith("user-1", { + data: { + eventCollection: [ + { + type: 1107, + cipherId: "5154f91d-c469-4d23-aefa-b12a0140d684", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T21:59:50.169Z", + }, + { + type: 1107, + cipherId: "ed4661bd-412c-4b05-89a2-b12a01697a2c", + organizationId: "278d5f91-835b-459a-a229-b11e01336d6d", + date: "2024-03-05T22:02:06.089Z", + }, + ], + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("should not try to restore values to missing accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).not.toHaveBeenCalledWith("user-3", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts b/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts new file mode 100644 index 0000000000..f2f4d94d19 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/41-move-event-collection-to-state-provider.ts @@ -0,0 +1,49 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedAccountState = { + data?: { + eventCollection?: []; + }; +}; + +const EVENT_COLLECTION: KeyDefinitionLike = { + stateDefinition: { + name: "eventCollection", + }, + key: "eventCollection", +}; + +export class EventCollectionMigrator extends Migrator<40, 41> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function migrateAccount(userId: string, account: ExpectedAccountState): Promise { + const value = account?.data?.eventCollection; + if (value != null) { + await helper.setToUser(userId, EVENT_COLLECTION, value); + delete account.data.eventCollection; + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise { + const value = await helper.getFromUser(userId, EVENT_COLLECTION); + if (account) { + account.data = Object.assign(account.data ?? {}, { + eventCollection: value, + }); + + await helper.set(userId, account); + } + await helper.setToUser(userId, EVENT_COLLECTION, null); + } + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +}