1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-18 09:13:33 +00:00

[AC-2156] Billing State Provider Migration (#8133)

* Added billing account profile state service

* Update usages after removing state service functions

* Added migrator

* Updated bw.ts and main.background.ts

* Removed comment

* Updated state service dependencies to include billing service

* Added missing mv3 factory and updated MainContextMenuHandler

* updated autofill service and tests

* Updated the remaining extensions usages

* Updated desktop

* Removed subjects where they weren't needed

* Refactored billing service to have a single setter to avoid unecessary emissions

* Refactored has premium guard to return an observable

* Renamed services to match ADR

f633f2cdd8/docs/architecture/clients/presentation/angular.md (abstract--default-implementations)

* Updated property names to be a smidgen more descriptive and added jsdocs

* Updated setting of canAccessPremium to automatically update when the underlying observable emits

* Fixed build error after merge conflicts

* Another build error from conflict

* Removed autofill unit test changes from conflict

* Updated login strategy to not set premium field using state service

* Updated CLI to use billing state provider

* Shortened names a bit

* Fixed build
This commit is contained in:
Conner Turnbull
2024-03-15 15:53:05 -04:00
committed by GitHub
parent 65534a1323
commit b99153a016
85 changed files with 942 additions and 261 deletions

View File

@@ -0,0 +1,36 @@
import { Observable } from "rxjs";
export type BillingAccountProfile = {
hasPremiumPersonally: boolean;
hasPremiumFromAnyOrganization: boolean;
};
export abstract class BillingAccountProfileStateService {
/**
* Emits `true` when the active user's account has been granted premium from any of the
* organizations it is a member of. Otherwise, emits `false`
*/
hasPremiumFromAnyOrganization$: Observable<boolean>;
/**
* Emits `true` when the active user's account has an active premium subscription at the
* individual user level
*/
hasPremiumPersonally$: Observable<boolean>;
/**
* Emits `true` when either `hasPremiumPersonally` or `hasPremiumFromAnyOrganization` is `true`
*/
hasPremiumFromAnySource$: Observable<boolean>;
/**
* Sets the active user's premium status fields upon every full sync, either from their personal
* subscription to premium, or an organization they're a part of that grants them premium.
* @param hasPremiumPersonally
* @param hasPremiumFromAnyOrganization
*/
abstract setHasPremium(
hasPremiumPersonally: boolean,
hasPremiumFromAnyOrganization: boolean,
): Promise<void>;
}

View File

@@ -0,0 +1,165 @@
import { firstValueFrom } from "rxjs";
import {
FakeAccountService,
FakeActiveUserStateProvider,
mockAccountServiceWith,
FakeActiveUserState,
trackEmissions,
} from "../../../../spec";
import { UserId } from "../../../types/guid";
import { BillingAccountProfile } from "../../abstractions/account/billing-account-profile-state.service";
import {
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
DefaultBillingAccountProfileStateService,
} from "./billing-account-profile-state.service";
describe("BillingAccountProfileStateService", () => {
let activeUserStateProvider: FakeActiveUserStateProvider;
let sut: DefaultBillingAccountProfileStateService;
let billingAccountProfileState: FakeActiveUserState<BillingAccountProfile>;
let accountService: FakeAccountService;
const userId = "fakeUserId" as UserId;
beforeEach(() => {
accountService = mockAccountServiceWith(userId);
activeUserStateProvider = new FakeActiveUserStateProvider(accountService);
sut = new DefaultBillingAccountProfileStateService(activeUserStateProvider);
billingAccountProfileState = activeUserStateProvider.getFake(
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
);
});
afterEach(() => {
return jest.resetAllMocks();
});
describe("accountHasPremiumFromAnyOrganization$", () => {
it("should emit changes in hasPremiumFromAnyOrganization", async () => {
billingAccountProfileState.nextState({
hasPremiumPersonally: false,
hasPremiumFromAnyOrganization: true,
});
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(true);
});
it("should emit once when calling setHasPremium once", async () => {
const emissions = trackEmissions(sut.hasPremiumFromAnyOrganization$);
const startingEmissionCount = emissions.length;
await sut.setHasPremium(true, true);
const endingEmissionCount = emissions.length;
expect(endingEmissionCount - startingEmissionCount).toBe(1);
});
});
describe("hasPremiumPersonally$", () => {
it("should emit changes in hasPremiumPersonally", async () => {
billingAccountProfileState.nextState({
hasPremiumPersonally: true,
hasPremiumFromAnyOrganization: false,
});
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true);
});
it("should emit once when calling setHasPremium once", async () => {
const emissions = trackEmissions(sut.hasPremiumPersonally$);
const startingEmissionCount = emissions.length;
await sut.setHasPremium(true, true);
const endingEmissionCount = emissions.length;
expect(endingEmissionCount - startingEmissionCount).toBe(1);
});
});
describe("canAccessPremium$", () => {
it("should emit changes in hasPremiumPersonally", async () => {
billingAccountProfileState.nextState({
hasPremiumPersonally: true,
hasPremiumFromAnyOrganization: false,
});
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
});
it("should emit changes in hasPremiumFromAnyOrganization", async () => {
billingAccountProfileState.nextState({
hasPremiumPersonally: false,
hasPremiumFromAnyOrganization: true,
});
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
});
it("should emit changes in both hasPremiumPersonally and hasPremiumFromAnyOrganization", async () => {
billingAccountProfileState.nextState({
hasPremiumPersonally: true,
hasPremiumFromAnyOrganization: true,
});
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
});
it("should emit once when calling setHasPremium once", async () => {
const emissions = trackEmissions(sut.hasPremiumFromAnySource$);
const startingEmissionCount = emissions.length;
await sut.setHasPremium(true, true);
const endingEmissionCount = emissions.length;
expect(endingEmissionCount - startingEmissionCount).toBe(1);
});
});
describe("setHasPremium", () => {
it("should have `hasPremiumPersonally$` emit `true` when passing `true` as an argument for hasPremiumPersonally", async () => {
await sut.setHasPremium(true, false);
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(true);
});
it("should have `hasPremiumFromAnyOrganization$` emit `true` when passing `true` as an argument for hasPremiumFromAnyOrganization", async () => {
await sut.setHasPremium(false, true);
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(true);
});
it("should have `hasPremiumPersonally$` emit `false` when passing `false` as an argument for hasPremiumPersonally", async () => {
await sut.setHasPremium(false, false);
expect(await firstValueFrom(sut.hasPremiumPersonally$)).toBe(false);
});
it("should have `hasPremiumFromAnyOrganization$` emit `false` when passing `false` as an argument for hasPremiumFromAnyOrganization", async () => {
await sut.setHasPremium(false, false);
expect(await firstValueFrom(sut.hasPremiumFromAnyOrganization$)).toBe(false);
});
it("should have `canAccessPremium$` emit `true` when passing `true` as an argument for hasPremiumPersonally", async () => {
await sut.setHasPremium(true, false);
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
});
it("should have `canAccessPremium$` emit `true` when passing `true` as an argument for hasPremiumFromAnyOrganization", async () => {
await sut.setHasPremium(false, true);
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(true);
});
it("should have `canAccessPremium$` emit `false` when passing `false` for all arguments", async () => {
await sut.setHasPremium(false, false);
expect(await firstValueFrom(sut.hasPremiumFromAnySource$)).toBe(false);
});
});
});

View File

@@ -0,0 +1,62 @@
import { map, Observable } from "rxjs";
import {
ActiveUserState,
ActiveUserStateProvider,
BILLING_DISK,
KeyDefinition,
} from "../../../platform/state";
import {
BillingAccountProfile,
BillingAccountProfileStateService,
} from "../../abstractions/account/billing-account-profile-state.service";
export const BILLING_ACCOUNT_PROFILE_KEY_DEFINITION = new KeyDefinition<BillingAccountProfile>(
BILLING_DISK,
"accountProfile",
{
deserializer: (billingAccountProfile) => billingAccountProfile,
},
);
export class DefaultBillingAccountProfileStateService implements BillingAccountProfileStateService {
private billingAccountProfileState: ActiveUserState<BillingAccountProfile>;
hasPremiumFromAnyOrganization$: Observable<boolean>;
hasPremiumPersonally$: Observable<boolean>;
hasPremiumFromAnySource$: Observable<boolean>;
constructor(activeUserStateProvider: ActiveUserStateProvider) {
this.billingAccountProfileState = activeUserStateProvider.get(
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
);
this.hasPremiumFromAnyOrganization$ = this.billingAccountProfileState.state$.pipe(
map((billingAccountProfile) => !!billingAccountProfile?.hasPremiumFromAnyOrganization),
);
this.hasPremiumPersonally$ = this.billingAccountProfileState.state$.pipe(
map((billingAccountProfile) => !!billingAccountProfile?.hasPremiumPersonally),
);
this.hasPremiumFromAnySource$ = this.billingAccountProfileState.state$.pipe(
map(
(billingAccountProfile) =>
billingAccountProfile?.hasPremiumFromAnyOrganization ||
billingAccountProfile?.hasPremiumPersonally,
),
);
}
async setHasPremium(
hasPremiumPersonally: boolean,
hasPremiumFromAnyOrganization: boolean,
): Promise<void> {
await this.billingAccountProfileState.update((billingAccountProfile) => {
return {
hasPremiumPersonally: hasPremiumPersonally,
hasPremiumFromAnyOrganization: hasPremiumFromAnyOrganization,
};
});
}
}

View File

@@ -61,11 +61,6 @@ export abstract class StateService<T extends Account = Account> {
setAutoConfirmFingerprints: (value: boolean, options?: StorageOptions) => Promise<void>;
getBiometricFingerprintValidated: (options?: StorageOptions) => Promise<boolean>;
setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise<void>;
getCanAccessPremium: (options?: StorageOptions) => Promise<boolean>;
getHasPremiumPersonally: (options?: StorageOptions) => Promise<boolean>;
setHasPremiumPersonally: (value: boolean, options?: StorageOptions) => Promise<void>;
setHasPremiumFromOrganization: (value: boolean, options?: StorageOptions) => Promise<void>;
getHasPremiumFromOrganization: (options?: StorageOptions) => Promise<boolean>;
getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise<boolean>;
setConvertAccountToKeyConnector: (value: boolean, options?: StorageOptions) => Promise<void>;
/**

View File

@@ -172,8 +172,6 @@ export class AccountProfile {
emailVerified?: boolean;
everBeenUnlocked?: boolean;
forceSetPasswordReason?: ForceSetPasswordReason;
hasPremiumPersonally?: boolean;
hasPremiumFromOrganization?: boolean;
lastSync?: string;
userId?: string;
usesKeyConnector?: boolean;

View File

@@ -338,72 +338,6 @@ export class StateService<
);
}
async getCanAccessPremium(options?: StorageOptions): Promise<boolean> {
if (!(await this.getIsAuthenticated(options))) {
return false;
}
return (
(await this.getHasPremiumPersonally(options)) ||
(await this.getHasPremiumFromOrganization(options))
);
}
async getHasPremiumPersonally(options?: StorageOptions): Promise<boolean> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
return account?.profile?.hasPremiumPersonally;
}
async setHasPremiumPersonally(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.profile.hasPremiumPersonally = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getHasPremiumFromOrganization(options?: StorageOptions): Promise<boolean> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
if (account.profile?.hasPremiumFromOrganization) {
return true;
}
// TODO: older server versions won't send the hasPremiumFromOrganization flag, so we're keeping the old logic
// for backwards compatibility. It can be removed after everyone has upgraded.
const organizations = await this.getOrganizations(options);
if (organizations == null) {
return false;
}
for (const id of Object.keys(organizations)) {
const o = organizations[id];
if (o.enabled && o.usersGetPremium && !o.isProviderUser) {
return true;
}
}
return false;
}
async setHasPremiumFromOrganization(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.profile.hasPremiumFromOrganization = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getConvertAccountToKeyConnector(options?: StorageOptions): Promise<boolean> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))

View File

@@ -23,6 +23,9 @@ export const ORGANIZATIONS_DISK = new StateDefinition("organizations", "disk");
export const POLICIES_DISK = new StateDefinition("policies", "disk");
export const PROVIDERS_DISK = new StateDefinition("providers", "disk");
// Billing
export const BILLING_DISK = new StateDefinition("billing", "disk");
// Auth
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
@@ -43,15 +46,11 @@ export const USER_NOTIFICATION_SETTINGS_DISK = new StateDefinition(
"disk",
);
// Billing
export const DOMAIN_SETTINGS_DISK = new StateDefinition("domainSettings", "disk");
export const AUTOFILL_SETTINGS_DISK = new StateDefinition("autofillSettings", "disk");
export const AUTOFILL_SETTINGS_DISK_LOCAL = new StateDefinition("autofillSettingsLocal", "disk", {
web: "disk-local",
});
export const BILLING_DISK = new StateDefinition("billing", "disk");
// Components

View File

@@ -33,6 +33,7 @@ import { MoveThemeToStateProviderMigrator } from "./migrations/35-move-theme-to-
import { VaultSettingsKeyMigrator } from "./migrations/36-move-show-card-and-identity-to-state-provider";
import { AvatarColorMigrator } from "./migrations/37-move-avatar-color-to-state-providers";
import { TokenServiceStateProviderMigrator } from "./migrations/38-migrate-token-svc-to-state-provider";
import { MoveBillingAccountProfileMigrator } from "./migrations/39-move-billing-account-profile-to-state-providers";
import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked";
import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
@@ -42,7 +43,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3;
export const CURRENT_VERSION = 38;
export const CURRENT_VERSION = 39;
export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() {
@@ -82,7 +83,8 @@ export function createMigrationBuilder() {
.with(MoveThemeToStateProviderMigrator, 34, 35)
.with(VaultSettingsKeyMigrator, 35, 36)
.with(AvatarColorMigrator, 36, 37)
.with(TokenServiceStateProviderMigrator, 37, CURRENT_VERSION);
.with(TokenServiceStateProviderMigrator, 37, 38)
.with(MoveBillingAccountProfileMigrator, 38, CURRENT_VERSION);
}
export async function currentVersion(

View File

@@ -0,0 +1,126 @@
import { MockProxy, any } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
MoveBillingAccountProfileMigrator,
} from "./39-move-billing-account-profile-to-state-providers";
const exampleJSON = () => ({
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
profile: {
hasPremiumPersonally: true,
hasPremiumFromOrganization: false,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
otherStuff: "otherStuff4",
},
});
const rollbackJSON = () => ({
"user_user-1_billing_accountProfile": {
hasPremiumPersonally: true,
hasPremiumFromOrganization: false,
},
global: {
otherStuff: "otherStuff1",
},
authenticatedAccounts: ["user-1", "user-2", "user-3"],
"user-1": {
profile: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
},
"user-2": {
otherStuff: "otherStuff4",
},
});
describe("MoveBillingAccountProfileToStateProviders migrator", () => {
let helper: MockProxy<MigrationHelper>;
let sut: MoveBillingAccountProfileMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(exampleJSON(), 39);
sut = new MoveBillingAccountProfileMigrator(38, 39);
});
it("removes from all accounts", async () => {
await sut.migrate(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
profile: {
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it("sets hasPremiumPersonally value for account that have it", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
{ hasPremiumFromOrganization: false, hasPremiumPersonally: true },
);
});
it("should not call extra setToUser", async () => {
await sut.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledTimes(1);
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJSON(), 39);
sut = new MoveBillingAccountProfileMigrator(38, 39);
});
it("nulls out new values", async () => {
await sut.rollback(helper);
expect(helper.setToUser).toHaveBeenCalledWith(
"user-1",
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
null,
);
});
it("adds explicit value back to accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledTimes(1);
expect(helper.set).toHaveBeenCalledWith("user-1", {
profile: {
hasPremiumPersonally: true,
hasPremiumFromOrganization: false,
otherStuff: "otherStuff2",
},
otherStuff: "otherStuff3",
});
});
it.each(["user-2", "user-3"])(
"does not restore values when accounts are not present",
async (userId) => {
await sut.rollback(helper);
expect(helper.set).not.toHaveBeenCalledWith(userId, any());
},
);
});
});

View File

@@ -0,0 +1,67 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
type ExpectedAccountType = {
profile?: {
hasPremiumPersonally?: boolean;
hasPremiumFromOrganization?: boolean;
};
};
type ExpectedBillingAccountProfileType = {
hasPremiumPersonally: boolean;
hasPremiumFromOrganization: boolean;
};
export const BILLING_ACCOUNT_PROFILE_KEY_DEFINITION: KeyDefinitionLike = {
key: "accountProfile",
stateDefinition: {
name: "billing",
},
};
export class MoveBillingAccountProfileMigrator extends Migrator<38, 39> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
const migrateAccount = async (userId: string, account: ExpectedAccountType): Promise<void> => {
const hasPremiumPersonally = account?.profile?.hasPremiumPersonally;
const hasPremiumFromOrganization = account?.profile?.hasPremiumFromOrganization;
if (hasPremiumPersonally != null || hasPremiumFromOrganization != null) {
await helper.setToUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, {
hasPremiumPersonally: hasPremiumPersonally,
hasPremiumFromOrganization: hasPremiumFromOrganization,
});
delete account?.profile?.hasPremiumPersonally;
delete account?.profile?.hasPremiumFromOrganization;
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<ExpectedAccountType>();
const rollbackAccount = async (userId: string, account: ExpectedAccountType): Promise<void> => {
const value = await helper.getFromUser<ExpectedBillingAccountProfileType>(
userId,
BILLING_ACCOUNT_PROFILE_KEY_DEFINITION,
);
if (account && value) {
account.profile = Object.assign(account.profile ?? {}, {
hasPremiumPersonally: value?.hasPremiumPersonally,
hasPremiumFromOrganization: value?.hasPremiumFromOrganization,
});
await helper.set(userId, account);
}
await helper.setToUser(userId, BILLING_ACCOUNT_PROFILE_KEY_DEFINITION, null);
};
await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]);
}
}

View File

@@ -11,6 +11,7 @@ import { AvatarService } from "../../../auth/abstractions/avatar.service";
import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
import { DomainSettingsService } from "../../../autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "../../../billing/abstractions/account/billing-account-profile-state.service";
import { DomainsResponse } from "../../../models/response/domains.response";
import {
SyncCipherNotification,
@@ -62,6 +63,7 @@ export class SyncService implements SyncServiceAbstraction {
private sendApiService: SendApiService,
private avatarService: AvatarService,
private logoutCallback: (expired: boolean) => Promise<void>,
private billingAccountProfileStateService: BillingAccountProfileStateService,
) {}
async getLastSync(): Promise<Date> {
@@ -314,8 +316,11 @@ export class SyncService implements SyncServiceAbstraction {
await this.avatarService.setAvatarColor(response.avatarColor);
await this.stateService.setSecurityStamp(response.securityStamp);
await this.stateService.setEmailVerified(response.emailVerified);
await this.stateService.setHasPremiumPersonally(response.premiumPersonally);
await this.stateService.setHasPremiumFromOrganization(response.premiumFromOrganization);
await this.billingAccountProfileStateService.setHasPremium(
response.premiumPersonally,
response.premiumFromOrganization,
);
await this.keyConnectorService.setUsesKeyConnector(response.usesKeyConnector);
await this.setForceSetPasswordReasonIfNeeded(response);