1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-26 01:23:24 +00:00

Refactor TwoFactorFormCacheService

This commit is contained in:
Alec Rippberger
2025-04-07 10:42:21 -05:00
parent ac279ec2a4
commit 7adc4eaee5
16 changed files with 158 additions and 489 deletions

View File

@@ -1,207 +0,0 @@
import { signal } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { firstValueFrom } from "rxjs";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { TwoFactorFormData } from "@bitwarden/auth/angular";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { ExtensionTwoFactorFormCacheService } from "./extension-two-factor-form-cache.service";
describe("ExtensionTwoFactorFormCacheService", () => {
let service: ExtensionTwoFactorFormCacheService;
let testBed: TestBed;
const formDataSignal = signal<TwoFactorFormData | null>(null);
const getFormDataSignal = jest.fn().mockReturnValue(formDataSignal);
const getFeatureFlag = jest.fn().mockResolvedValue(false);
const formDataSetMock = jest.spyOn(formDataSignal, "set");
const mockFormData: TwoFactorFormData = {
token: "123456",
remember: true,
selectedProviderType: TwoFactorProviderType.Authenticator,
emailSent: false,
};
beforeEach(() => {
getFormDataSignal.mockClear();
getFeatureFlag.mockClear();
formDataSetMock.mockClear();
testBed = TestBed.configureTestingModule({
providers: [
{ provide: ViewCacheService, useValue: { signal: getFormDataSignal } },
{ provide: ConfigService, useValue: { getFeatureFlag } },
ExtensionTwoFactorFormCacheService,
],
});
});
describe("feature enabled", () => {
beforeEach(async () => {
getFeatureFlag.mockImplementation((featureFlag: FeatureFlag) => {
if (featureFlag === FeatureFlag.PM9115_TwoFactorExtensionDataPersistence) {
return Promise.resolve(true);
}
return Promise.resolve(false);
});
service = testBed.inject(ExtensionTwoFactorFormCacheService);
});
describe("isEnabled$", () => {
it("emits true when feature flag is on", async () => {
const result = await firstValueFrom(service.isEnabled$());
expect(result).toBe(true);
expect(getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
});
});
describe("isEnabled", () => {
it("returns true when feature flag is on", async () => {
const result = await service.isEnabled();
expect(result).toBe(true);
expect(getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
});
});
describe("getFormData", () => {
it("returns cached form data", async () => {
formDataSignal.set(mockFormData);
const result = await service.getFormData();
expect(result).toEqual(mockFormData);
});
it("returns null when cache is empty", async () => {
formDataSignal.set(null);
const result = await service.getFormData();
expect(result).toBeNull();
});
});
describe("formData$", () => {
it("emits cached form data", async () => {
formDataSignal.set(mockFormData);
const result = await firstValueFrom(service.formData$());
expect(result).toEqual(mockFormData);
});
it("emits null when cache is empty", async () => {
formDataSignal.set(null);
const result = await firstValueFrom(service.formData$());
expect(result).toBeNull();
});
});
describe("saveFormData", () => {
it("updates the cached form data", async () => {
await service.saveFormData(mockFormData);
expect(formDataSetMock).toHaveBeenCalledWith({ ...mockFormData });
});
it("creates a shallow copy of the data", async () => {
const data = { ...mockFormData };
await service.saveFormData(data);
expect(formDataSetMock).toHaveBeenCalledWith(data);
// Should be a new object, not the same reference
expect(formDataSetMock.mock.calls[0][0]).not.toBe(data);
});
});
describe("clearFormData", () => {
it("sets the cache to null", async () => {
await service.clearFormData();
expect(formDataSetMock).toHaveBeenCalledWith(null);
});
});
});
describe("feature disabled", () => {
beforeEach(async () => {
formDataSignal.set(mockFormData);
getFeatureFlag.mockImplementation((featureFlag: FeatureFlag) => {
if (featureFlag === FeatureFlag.PM9115_TwoFactorExtensionDataPersistence) {
return Promise.resolve(false);
}
return Promise.resolve(false);
});
service = testBed.inject(ExtensionTwoFactorFormCacheService);
formDataSetMock.mockClear();
});
describe("isEnabled$", () => {
it("emits false when feature flag is off", async () => {
const result = await firstValueFrom(service.isEnabled$());
expect(result).toBe(false);
expect(getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
});
});
describe("isEnabled", () => {
it("returns false when feature flag is off", async () => {
const result = await service.isEnabled();
expect(result).toBe(false);
expect(getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
});
});
describe("formData$", () => {
it("emits null when feature is disabled", async () => {
const result = await firstValueFrom(service.formData$());
expect(result).toBeNull();
});
});
describe("getFormData", () => {
it("returns null when feature is disabled", async () => {
const result = await service.getFormData();
expect(result).toBeNull();
});
});
describe("saveFormData", () => {
it("does not update cache when feature is disabled", async () => {
await service.saveFormData(mockFormData);
expect(formDataSetMock).not.toHaveBeenCalled();
});
});
describe("clearFormData", () => {
it("still works when feature is disabled", async () => {
await service.clearFormData();
expect(formDataSetMock).toHaveBeenCalledWith(null);
});
});
});
});

View File

@@ -1,93 +0,0 @@
import { Injectable, WritableSignal } from "@angular/core";
import { Observable, of, switchMap, from } from "rxjs";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { TwoFactorFormCacheService, TwoFactorFormData } from "@bitwarden/auth/angular";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
const TWO_FACTOR_FORM_CACHE_KEY = "two-factor-form-cache";
// Utilize function overloading to create a type-safe deserializer to match the exact expected signature
function deserializeFormData(jsonValue: null): null;
function deserializeFormData(jsonValue: {
token?: string;
remember?: boolean;
selectedProviderType?: TwoFactorProviderType;
emailSent?: boolean;
}): TwoFactorFormData;
function deserializeFormData(jsonValue: any): TwoFactorFormData | null {
if (!jsonValue) {
return null;
}
return {
token: jsonValue.token,
remember: jsonValue.remember,
selectedProviderType: jsonValue.selectedProviderType,
emailSent: jsonValue.emailSent,
};
}
/**
* Service for caching two-factor form data
*/
@Injectable()
export class ExtensionTwoFactorFormCacheService extends TwoFactorFormCacheService {
private formDataCache: WritableSignal<TwoFactorFormData | null>;
constructor(
private viewCacheService: ViewCacheService,
private configService: ConfigService,
) {
super();
this.formDataCache = this.viewCacheService.signal<TwoFactorFormData | null>({
key: TWO_FACTOR_FORM_CACHE_KEY,
initialValue: null,
deserializer: deserializeFormData,
});
}
/**
* Observable that emits the current enabled state
*/
isEnabled$(): Observable<boolean> {
return from(
this.configService.getFeatureFlag(FeatureFlag.PM9115_TwoFactorExtensionDataPersistence),
);
}
/**
* Observable that emits the current form data
*/
formData$(): Observable<TwoFactorFormData | null> {
return this.isEnabled$().pipe(
switchMap((enabled) => {
if (!enabled) {
return of(null);
}
return of(this.formDataCache());
}),
);
}
/**
* Save form data to cache
*/
async saveFormData(data: TwoFactorFormData): Promise<void> {
if (!(await this.isEnabled())) {
return;
}
// Set the new form data in the cache
this.formDataCache.set({ ...data });
}
/**
* Clear form data from cache
*/
async clearFormData(): Promise<void> {
this.formDataCache.set(null);
}
}

View File

@@ -31,7 +31,6 @@ import {
TwoFactorAuthDuoComponentService,
TwoFactorAuthWebAuthnComponentService,
SsoComponentService,
TwoFactorFormCacheService,
} from "@bitwarden/auth/angular";
import {
LockService,
@@ -147,7 +146,6 @@ import { ExtensionTwoFactorAuthComponentService } from "../../auth/services/exte
import { ExtensionTwoFactorAuthDuoComponentService } from "../../auth/services/extension-two-factor-auth-duo-component.service";
import { ExtensionTwoFactorAuthEmailComponentService } from "../../auth/services/extension-two-factor-auth-email-component.service";
import { ExtensionTwoFactorAuthWebAuthnComponentService } from "../../auth/services/extension-two-factor-auth-webauthn-component.service";
import { ExtensionTwoFactorFormCacheService } from "../../auth/services/extension-two-factor-form-cache.service";
import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service";
import AutofillService from "../../autofill/services/autofill.service";
import { InlineMenuFieldQualificationService } from "../../autofill/services/inline-menu-field-qualification.service";
@@ -566,11 +564,6 @@ const safeProviders: SafeProvider[] = [
useClass: ExtensionTwoFactorAuthWebAuthnComponentService,
deps: [],
}),
safeProvider({
provide: TwoFactorFormCacheService,
useClass: ExtensionTwoFactorFormCacheService,
deps: [PopupViewCacheService, ConfigService],
}),
safeProvider({
provide: TwoFactorAuthDuoComponentService,
useClass: ExtensionTwoFactorAuthDuoComponentService,

View File

@@ -28,7 +28,6 @@ import {
SsoComponentService,
DefaultSsoComponentService,
TwoFactorAuthDuoComponentService,
TwoFactorFormCacheService,
} from "@bitwarden/auth/angular";
import {
InternalUserDecryptionOptionsServiceAbstraction,
@@ -110,7 +109,6 @@ import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarde
import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-login-approval-component.service";
import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service";
import { DesktopTwoFactorAuthDuoComponentService } from "../../auth/services/desktop-two-factor-auth-duo-component.service";
import { DesktopTwoFactorFormCacheService } from "../../auth/services/desktop-two-factor-form-cache.service";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
import { DesktopFido2UserInterfaceService } from "../../autofill/services/desktop-fido2-user-interface.service";
@@ -423,11 +421,6 @@ const safeProviders: SafeProvider[] = [
PlatformUtilsServiceAbstraction,
],
}),
safeProvider({
provide: TwoFactorFormCacheService,
useClass: DesktopTwoFactorFormCacheService,
deps: [],
}),
safeProvider({
provide: SdkClientFactory,
useClass: flagEnabled("sdk") ? DefaultSdkClientFactory : NoopSdkClientFactory,

View File

@@ -1,30 +0,0 @@
import { Injectable } from "@angular/core";
import { Observable, of } from "rxjs";
import { TwoFactorFormCacheService, TwoFactorFormData } from "@bitwarden/auth/angular";
/**
* No-op implementation of TwoFactorFormCacheService for desktop
*/
@Injectable()
export class DesktopTwoFactorFormCacheService extends TwoFactorFormCacheService {
constructor() {
super();
}
isEnabled$(): Observable<boolean> {
return of(false);
}
formData$(): Observable<TwoFactorFormData | null> {
return of(null);
}
async saveFormData(): Promise<void> {
return Promise.resolve();
}
async clearFormData(): Promise<void> {
return Promise.resolve();
}
}

View File

@@ -1,3 +1,2 @@
export * from "./web-two-factor-auth-component.service";
export * from "./web-two-factor-auth-duo-component.service";
export * from "./web-two-factor-form-cache.service";

View File

@@ -1,30 +0,0 @@
import { Injectable } from "@angular/core";
import { Observable, of } from "rxjs";
import { TwoFactorFormCacheService, TwoFactorFormData } from "@bitwarden/auth/angular";
/**
* No-op implementation of TwoFactorFormCacheService for web app
*/
@Injectable()
export class WebTwoFactorFormCacheService extends TwoFactorFormCacheService {
constructor() {
super();
}
isEnabled$(): Observable<boolean> {
return of(false);
}
formData$(): Observable<TwoFactorFormData | null> {
return of(null);
}
async saveFormData(): Promise<void> {
return Promise.resolve();
}
async clearFormData(): Promise<void> {
return Promise.resolve();
}
}

View File

@@ -35,7 +35,6 @@ import {
LoginDecryptionOptionsService,
TwoFactorAuthComponentService,
TwoFactorAuthDuoComponentService,
TwoFactorFormCacheService,
} from "@bitwarden/auth/angular";
import {
InternalUserDecryptionOptionsServiceAbstraction,
@@ -120,7 +119,6 @@ import {
LinkSsoService,
} from "../auth";
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
import { WebTwoFactorFormCacheService } from "../auth/core/services/two-factor-auth/web-two-factor-form-cache.service";
import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service";
import { HtmlStorageService } from "../core/html-storage.service";
import { I18nService } from "../core/i18n.service";
@@ -279,11 +277,6 @@ const safeProviders: SafeProvider[] = [
useClass: WebTwoFactorAuthComponentService,
deps: [],
}),
safeProvider({
provide: TwoFactorFormCacheService,
useClass: WebTwoFactorFormCacheService,
deps: [],
}),
safeProvider({
provide: SetPasswordJitService,
useClass: WebSetPasswordJitService,