1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 22:03:36 +00:00

[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
This commit is contained in:
Tom
2024-03-18 14:36:43 -04:00
committed by GitHub
parent 2b92c7dd10
commit cc28149e60
19 changed files with 381 additions and 97 deletions

View File

@@ -727,14 +727,16 @@ export default class MainBackground {
); );
this.eventUploadService = new EventUploadService( this.eventUploadService = new EventUploadService(
this.apiService, this.apiService,
this.stateService, this.stateProvider,
this.logService, this.logService,
this.accountService,
); );
this.eventCollectionService = new EventCollectionService( this.eventCollectionService = new EventCollectionService(
this.cipherService, this.cipherService,
this.stateService, this.stateProvider,
this.organizationService, this.organizationService,
this.eventUploadService, this.eventUploadService,
this.accountService,
); );
this.totpService = new TotpService(this.cryptoFunctionService, this.logService); this.totpService = new TotpService(this.cryptoFunctionService, this.logService);
@@ -1108,7 +1110,7 @@ export default class MainBackground {
async logout(expired: boolean, userId?: UserId) { async logout(expired: boolean, userId?: UserId) {
userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id;
await this.eventUploadService.uploadEvents(userId); await this.eventUploadService.uploadEvents(userId as UserId);
await Promise.all([ await Promise.all([
this.syncService.setLastSync(new Date(0), userId), this.syncService.setLastSync(new Date(0), userId),

View File

@@ -5,15 +5,14 @@ import {
organizationServiceFactory, organizationServiceFactory,
OrganizationServiceInitOptions, OrganizationServiceInitOptions,
} from "../../admin-console/background/service-factories/organization-service.factory"; } from "../../admin-console/background/service-factories/organization-service.factory";
import { accountServiceFactory } from "../../auth/background/service-factories/account-service.factory";
import { import {
FactoryOptions, FactoryOptions,
CachedServices, CachedServices,
factory, factory,
} from "../../platform/background/service-factories/factory-options"; } from "../../platform/background/service-factories/factory-options";
import { import { stateProviderFactory } from "../../platform/background/service-factories/state-provider.factory";
stateServiceFactory, import { StateServiceInitOptions } from "../../platform/background/service-factories/state-service.factory";
StateServiceInitOptions,
} from "../../platform/background/service-factories/state-service.factory";
import { import {
cipherServiceFactory, cipherServiceFactory,
CipherServiceInitOptions, CipherServiceInitOptions,
@@ -43,9 +42,10 @@ export function eventCollectionServiceFactory(
async () => async () =>
new EventCollectionService( new EventCollectionService(
await cipherServiceFactory(cache, opts), await cipherServiceFactory(cache, opts),
await stateServiceFactory(cache, opts), await stateProviderFactory(cache, opts),
await organizationServiceFactory(cache, opts), await organizationServiceFactory(cache, opts),
await eventUploadServiceFactory(cache, opts), await eventUploadServiceFactory(cache, opts),
await accountServiceFactory(cache, opts),
), ),
); );
} }

View File

@@ -1,6 +1,7 @@
import { EventUploadService as AbstractEventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { EventUploadService as AbstractEventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { EventUploadService } from "@bitwarden/common/services/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 { import {
ApiServiceInitOptions, ApiServiceInitOptions,
apiServiceFactory, apiServiceFactory,
@@ -14,10 +15,8 @@ import {
logServiceFactory, logServiceFactory,
LogServiceInitOptions, LogServiceInitOptions,
} from "../../platform/background/service-factories/log-service.factory"; } from "../../platform/background/service-factories/log-service.factory";
import { import { stateProviderFactory } from "../../platform/background/service-factories/state-provider.factory";
stateServiceFactory, import { StateServiceInitOptions } from "../../platform/background/service-factories/state-service.factory";
StateServiceInitOptions,
} from "../../platform/background/service-factories/state-service.factory";
type EventUploadServiceOptions = FactoryOptions; type EventUploadServiceOptions = FactoryOptions;
@@ -37,8 +36,9 @@ export function eventUploadServiceFactory(
async () => async () =>
new EventUploadService( new EventUploadService(
await apiServiceFactory(cache, opts), await apiServiceFactory(cache, opts),
await stateServiceFactory(cache, opts), await stateProviderFactory(cache, opts),
await logServiceFactory(cache, opts), await logServiceFactory(cache, opts),
await accountServiceFactory(cache, opts),
), ),
); );
} }

View File

@@ -17,8 +17,6 @@ import {
LoginStrategyServiceAbstraction, LoginStrategyServiceAbstraction,
} from "@bitwarden/auth/common"; } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; 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 { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service"; import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service";
import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
@@ -263,16 +261,6 @@ function getBgService<T>(service: keyof MainBackground) {
useFactory: getBgService<DevicesServiceAbstraction>("devicesService"), useFactory: getBgService<DevicesServiceAbstraction>("devicesService"),
deps: [], deps: [],
}, },
{
provide: EventUploadService,
useFactory: getBgService<EventUploadService>("eventUploadService"),
deps: [],
},
{
provide: EventCollectionService,
useFactory: getBgService<EventCollectionService>("eventCollectionService"),
deps: [],
},
{ {
provide: PlatformUtilsService, provide: PlatformUtilsService,
useExisting: ForegroundPlatformUtilsService, useExisting: ForegroundPlatformUtilsService,

View File

@@ -641,15 +641,17 @@ export class Main {
this.eventUploadService = new EventUploadService( this.eventUploadService = new EventUploadService(
this.apiService, this.apiService,
this.stateService, this.stateProvider,
this.logService, this.logService,
this.accountService,
); );
this.eventCollectionService = new EventCollectionService( this.eventCollectionService = new EventCollectionService(
this.cipherService, this.cipherService,
this.stateService, this.stateProvider,
this.organizationService, this.organizationService,
this.eventUploadService, this.eventUploadService,
this.accountService,
); );
} }
@@ -673,6 +675,7 @@ export class Main {
}); });
const userId = await this.stateService.getUserId(); const userId = await this.stateService.getUserId();
await Promise.all([ await Promise.all([
this.eventUploadService.uploadEvents(userId as UserId),
this.syncService.setLastSync(new Date(0)), this.syncService.setLastSync(new Date(0)),
this.cryptoService.clearKeys(), this.cryptoService.clearKeys(),
this.cipherService.clear(userId), this.cipherService.clear(userId),

View File

@@ -576,7 +576,8 @@ export class AppComponent implements OnInit, OnDestroy {
let preLogoutActiveUserId; let preLogoutActiveUserId;
try { 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.syncService.setLastSync(new Date(0), userBeingLoggedOut);
await this.cryptoService.clearKeys(userBeingLoggedOut); await this.cryptoService.clearKeys(userBeingLoggedOut);
await this.cipherService.clear(userBeingLoggedOut); await this.cipherService.clear(userBeingLoggedOut);

View File

@@ -721,16 +721,17 @@ const typesafeProviders: Array<SafeProvider> = [
safeProvider({ safeProvider({
provide: EventUploadServiceAbstraction, provide: EventUploadServiceAbstraction,
useClass: EventUploadService, useClass: EventUploadService,
deps: [ApiServiceAbstraction, StateServiceAbstraction, LogService], deps: [ApiServiceAbstraction, StateProvider, LogService, AccountServiceAbstraction],
}), }),
safeProvider({ safeProvider({
provide: EventCollectionServiceAbstraction, provide: EventCollectionServiceAbstraction,
useClass: EventCollectionService, useClass: EventCollectionService,
deps: [ deps: [
CipherServiceAbstraction, CipherServiceAbstraction,
StateServiceAbstraction, StateProvider,
OrganizationServiceAbstraction, OrganizationServiceAbstraction,
EventUploadServiceAbstraction, EventUploadServiceAbstraction,
AccountServiceAbstraction,
], ],
}), }),
safeProvider({ safeProvider({

View File

@@ -1,3 +1,5 @@
import { UserId } from "../../types/guid";
export abstract class EventUploadService { export abstract class EventUploadService {
uploadEvents: (userId?: string) => Promise<void>; uploadEvents: (userId?: UserId) => Promise<void>;
} }

View File

@@ -1,3 +1,5 @@
import { Jsonify } from "type-fest";
import { EventType } from "../../enums"; import { EventType } from "../../enums";
export class EventData { export class EventData {
@@ -5,4 +7,8 @@ export class EventData {
cipherId: string; cipherId: string;
date: string; date: string;
organizationId: string; organizationId: string;
static fromJSON(obj: Jsonify<EventData>): EventData {
return Object.assign(new EventData(), obj);
}
} }

View File

@@ -4,7 +4,6 @@ import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-re
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { KdfConfig } from "../../auth/models/domain/kdf-config";
import { BiometricKey } from "../../auth/types/biometric-key"; import { BiometricKey } from "../../auth/types/biometric-key";
import { EventData } from "../../models/data/event.data";
import { WindowState } from "../../models/domain/window-state"; import { WindowState } from "../../models/domain/window-state";
import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratorOptions } from "../../tools/generator/generator-options";
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
@@ -253,8 +252,6 @@ export abstract class StateService<T extends Account = Account> {
* @deprecated Do not call this directly, use SendService * @deprecated Do not call this directly, use SendService
*/ */
setEncryptedSends: (value: { [id: string]: SendData }, options?: StorageOptions) => Promise<void>; setEncryptedSends: (value: { [id: string]: SendData }, options?: StorageOptions) => Promise<void>;
getEventCollection: (options?: StorageOptions) => Promise<EventData[]>;
setEventCollection: (value: EventData[], options?: StorageOptions) => Promise<void>;
getEverBeenUnlocked: (options?: StorageOptions) => Promise<boolean>; getEverBeenUnlocked: (options?: StorageOptions) => Promise<boolean>;
setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise<void>; setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise<void>;
getForceSetPasswordReason: (options?: StorageOptions) => Promise<ForceSetPasswordReason>; getForceSetPasswordReason: (options?: StorageOptions) => Promise<ForceSetPasswordReason>;

View File

@@ -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 { 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 { TrustedDeviceUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/trusted-device-user-decryption-option";
import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response"; import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response";
import { EventData } from "../../../models/data/event.data";
import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { UriMatchStrategySetting } from "../../../models/domain/domain-service";
import { GeneratorOptions } from "../../../tools/generator/generator-options"; import { GeneratorOptions } from "../../../tools/generator/generator-options";
import { import {
@@ -89,7 +88,6 @@ export class AccountData {
GeneratedPasswordHistory[] GeneratedPasswordHistory[]
> = new EncryptionPair<GeneratedPasswordHistory[], GeneratedPasswordHistory[]>(); > = new EncryptionPair<GeneratedPasswordHistory[], GeneratedPasswordHistory[]>();
addEditCipherInfo?: AddEditCipherInfo; addEditCipherInfo?: AddEditCipherInfo;
eventCollection?: EventData[];
static fromJSON(obj: DeepJsonify<AccountData>): AccountData { static fromJSON(obj: DeepJsonify<AccountData>): AccountData {
if (obj == null) { if (obj == null) {

View File

@@ -8,7 +8,6 @@ import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-re
import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason";
import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { KdfConfig } from "../../auth/models/domain/kdf-config";
import { BiometricKey } from "../../auth/types/biometric-key"; import { BiometricKey } from "../../auth/types/biometric-key";
import { EventData } from "../../models/data/event.data";
import { WindowState } from "../../models/domain/window-state"; import { WindowState } from "../../models/domain/window-state";
import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratorOptions } from "../../tools/generator/generator-options";
import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password";
@@ -1176,24 +1175,6 @@ export class StateService<
); );
} }
@withPrototypeForArrayMembers(EventData)
async getEventCollection(options?: StorageOptions): Promise<EventData[]> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.data?.eventCollection;
}
async setEventCollection(value: EventData[], options?: StorageOptions): Promise<void> {
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<boolean> { async getEverBeenUnlocked(options?: StorageOptions): Promise<boolean> {
return ( return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))) (await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())))

View File

@@ -81,6 +81,7 @@ export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", {
export const GENERATOR_DISK = new StateDefinition("generator", "disk"); export const GENERATOR_DISK = new StateDefinition("generator", "disk");
export const GENERATOR_MEMORY = new StateDefinition("generator", "memory"); export const GENERATOR_MEMORY = new StateDefinition("generator", "memory");
export const EVENT_COLLECTION_DISK = new StateDefinition("eventCollection", "disk");
// Vault // Vault

View File

@@ -1,61 +1,105 @@
import { firstValueFrom, map, from, zip } from "rxjs";
import { EventCollectionService as EventCollectionServiceAbstraction } from "../../abstractions/event/event-collection.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "../../abstractions/event/event-collection.service";
import { EventUploadService } from "../../abstractions/event/event-upload.service"; import { EventUploadService } from "../../abstractions/event/event-upload.service";
import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction"; 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 { EventType } from "../../enums";
import { EventData } from "../../models/data/event.data"; 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 { CipherService } from "../../vault/abstractions/cipher.service";
import { EVENT_COLLECTION } from "./key-definitions";
export class EventCollectionService implements EventCollectionServiceAbstraction { export class EventCollectionService implements EventCollectionServiceAbstraction {
constructor( constructor(
private cipherService: CipherService, private cipherService: CipherService,
private stateService: StateService, private stateProvider: StateProvider,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private eventUploadService: EventUploadService, 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( async collect(
eventType: EventType, eventType: EventType,
cipherId: string = null, cipherId: string = null,
uploadImmediately = false, uploadImmediately = false,
organizationId: string = null, organizationId: string = null,
): Promise<any> { ): Promise<any> {
const authed = await this.stateService.getIsAuthenticated(); const userId = await firstValueFrom(this.stateProvider.activeUserId$);
if (!authed) { const eventStore = this.stateProvider.getUser(userId, EVENT_COLLECTION);
if (!(await this.shouldUpdate(cipherId, organizationId))) {
return; return;
} }
const organizations = await this.organizationService.getAll();
if (organizations == null) {
return;
}
const orgIds = new Set<string>(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(); const event = new EventData();
event.type = eventType; event.type = eventType;
event.cipherId = cipherId; event.cipherId = cipherId;
event.date = new Date().toISOString(); event.date = new Date().toISOString();
event.organizationId = organizationId; event.organizationId = organizationId;
eventCollection.push(event);
await this.stateService.setEventCollection(eventCollection); await eventStore.update((events) => {
events = events ?? [];
events.push(event);
return events;
});
if (uploadImmediately) { if (uploadImmediately) {
await this.eventUploadService.uploadEvents(); 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<boolean> {
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;
}
} }

View File

@@ -1,15 +1,24 @@
import { firstValueFrom, map } from "rxjs";
import { ApiService } from "../../abstractions/api.service"; import { ApiService } from "../../abstractions/api.service";
import { EventUploadService as EventUploadServiceAbstraction } from "../../abstractions/event/event-upload.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 { EventRequest } from "../../models/request/event.request";
import { LogService } from "../../platform/abstractions/log.service"; 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 { export class EventUploadService implements EventUploadServiceAbstraction {
private inited = false; private inited = false;
constructor( constructor(
private apiService: ApiService, private apiService: ApiService,
private stateService: StateService, private stateProvider: StateProvider,
private logService: LogService, private logService: LogService,
private accountService: AccountService,
) {} ) {}
init(checkOnInterval: boolean) { init(checkOnInterval: boolean) {
@@ -26,12 +35,26 @@ export class EventUploadService implements EventUploadServiceAbstraction {
} }
} }
async uploadEvents(userId?: string): Promise<void> { /** Upload the event collection from state.
const authed = await this.stateService.getIsAuthenticated({ userId: userId }); * @param userId upload events for provided user. If not active user will be used.
if (!authed) { */
async uploadEvents(userId?: UserId): Promise<void> {
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; return;
} }
const eventCollection = await this.stateService.getEventCollection({ userId: userId });
const eventCollection = await this.takeEvents(userId);
if (eventCollection == null || eventCollection.length === 0) { if (eventCollection == null || eventCollection.length === 0) {
return; return;
} }
@@ -45,15 +68,23 @@ export class EventUploadService implements EventUploadServiceAbstraction {
}); });
try { try {
await this.apiService.postEventsCollect(request); 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) { } catch (e) {
this.logService.error(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<any> { /** Return user's events and then clear them from state
await this.stateService.setEventCollection(null, { userId: userId }); * @param userId the user to grab and clear events for
*/
private async takeEvents(userId: UserId): Promise<EventData[]> {
let taken = null;
await this.stateProvider.getUser(userId, EVENT_COLLECTION).update((current) => {
taken = current ?? [];
return [];
});
return taken;
} }
} }

View File

@@ -0,0 +1,10 @@
import { EventData } from "../../models/data/event.data";
import { KeyDefinition, EVENT_COLLECTION_DISK } from "../../platform/state";
export const EVENT_COLLECTION: KeyDefinition<EventData[]> = KeyDefinition.array<EventData>(
EVENT_COLLECTION_DISK,
"events",
{
deserializer: (s) => EventData.fromJSON(s),
},
);

View File

@@ -36,6 +36,7 @@ import { TokenServiceStateProviderMigrator } from "./migrations/38-migrate-token
import { MoveBillingAccountProfileMigrator } from "./migrations/39-move-billing-account-profile-to-state-providers"; import { MoveBillingAccountProfileMigrator } from "./migrations/39-move-billing-account-profile-to-state-providers";
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
import { OrganizationMigrator } from "./migrations/40-move-organization-state-to-state-provider"; 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 { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; 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"; import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3; export const MIN_VERSION = 3;
export const CURRENT_VERSION = 40; export const CURRENT_VERSION = 41;
export type MinVersion = typeof MIN_VERSION; export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() { export function createMigrationBuilder() {
@@ -86,7 +87,8 @@ export function createMigrationBuilder() {
.with(AvatarColorMigrator, 36, 37) .with(AvatarColorMigrator, 36, 37)
.with(TokenServiceStateProviderMigrator, 37, 38) .with(TokenServiceStateProviderMigrator, 37, 38)
.with(MoveBillingAccountProfileMigrator, 38, 39) .with(MoveBillingAccountProfileMigrator, 38, 39)
.with(OrganizationMigrator, 39, CURRENT_VERSION); .with(OrganizationMigrator, 39, 40)
.with(EventCollectionMigrator, 40, CURRENT_VERSION);
} }
export async function currentVersion( export async function currentVersion(

View File

@@ -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<MigrationHelper>;
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());
});
});
});

View File

@@ -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<void> {
const accounts = await helper.getAccounts<ExpectedAccountState>();
async function migrateAccount(userId: string, account: ExpectedAccountState): Promise<void> {
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<void> {
const accounts = await helper.getAccounts<ExpectedAccountState>();
async function rollbackAccount(userId: string, account: ExpectedAccountState): Promise<void> {
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))]);
}
}