From 561950477894f5a651ba96760d46076939529b89 Mon Sep 17 00:00:00 2001 From: Nick Krantz Date: Tue, 1 Oct 2024 15:15:18 -0500 Subject: [PATCH] integrate username and password generators into browser extension --- .../cipher-form-generator.component.html | 68 +---- .../cipher-form-generator.component.spec.ts | 244 +++++------------- .../cipher-form-generator.component.ts | 167 +----------- 3 files changed, 84 insertions(+), 395 deletions(-) diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.html b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.html index 0a375d5ae5..3684d9e292 100644 --- a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.html +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.html @@ -1,62 +1,10 @@ - - - - {{ "password" | i18n }} - - - {{ "passphrase" | i18n }} - - - - - - - - - - - - - - - - - - - - - - - -

{{ "options" | i18n }}

-
- - Placeholder: Replace with Generator Options Component(s) when available - + +
diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts index 85ace2f0ac..48b1250eb1 100644 --- a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.spec.ts @@ -1,217 +1,103 @@ -import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; -import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject } from "rxjs"; +import { Component, EventEmitter, Output } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { - PasswordGenerationServiceAbstraction, - PasswordGeneratorOptions, - UsernameGenerationServiceAbstraction, - UsernameGeneratorOptions, -} from "@bitwarden/generator-legacy"; + PasswordGeneratorComponent, + UsernameGeneratorComponent, +} from "@bitwarden/generator-components"; import { CipherFormGeneratorComponent } from "@bitwarden/vault"; +@Component({ + selector: "tools-password-generator", + template: ``, + standalone: true, +}) +class MockPasswordGeneratorComponent { + @Output() onGenerated = new EventEmitter(); +} + +@Component({ + selector: "tools-username-generator", + template: ``, + standalone: true, +}) +class MockUsernameGeneratorComponent { + @Output() onGenerated = new EventEmitter(); +} + describe("CipherFormGeneratorComponent", () => { let component: CipherFormGeneratorComponent; let fixture: ComponentFixture; - let mockLegacyPasswordGenerationService: MockProxy; - let mockLegacyUsernameGenerationService: MockProxy; - let mockPlatformUtilsService: MockProxy; - - let passwordOptions$: BehaviorSubject; - let usernameOptions$: BehaviorSubject; - beforeEach(async () => { - passwordOptions$ = new BehaviorSubject([ - { - type: "password", - }, - ] as [PasswordGeneratorOptions]); - usernameOptions$ = new BehaviorSubject([ - { - type: "word", - }, - ] as [UsernameGeneratorOptions]); - - mockPlatformUtilsService = mock(); - - mockLegacyPasswordGenerationService = mock(); - mockLegacyPasswordGenerationService.getOptions$.mockReturnValue(passwordOptions$); - - mockLegacyUsernameGenerationService = mock(); - mockLegacyUsernameGenerationService.getOptions$.mockReturnValue(usernameOptions$); - await TestBed.configureTestingModule({ imports: [CipherFormGeneratorComponent], - providers: [ - { provide: I18nService, useValue: { t: (key: string) => key } }, - { - provide: PasswordGenerationServiceAbstraction, - useValue: mockLegacyPasswordGenerationService, - }, - { - provide: UsernameGenerationServiceAbstraction, - useValue: mockLegacyUsernameGenerationService, - }, - { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, - ], - }).compileComponents(); + providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }], + }) + .overrideComponent(CipherFormGeneratorComponent, { + remove: { imports: [PasswordGeneratorComponent, UsernameGeneratorComponent] }, + add: { imports: [MockPasswordGeneratorComponent, MockUsernameGeneratorComponent] }, + }) + .compileComponents(); fixture = TestBed.createComponent(CipherFormGeneratorComponent); component = fixture.componentInstance; - }); - - it("should create", () => { fixture.detectChanges(); - expect(component).toBeTruthy(); }); - it("should use the appropriate text based on generator type", () => { - component.type = "password"; - component.ngOnChanges(); - expect(component["regenerateButtonTitle"]).toBe("regeneratePassword"); - - component.type = "username"; - component.ngOnChanges(); - expect(component["regenerateButtonTitle"]).toBe("regenerateUsername"); - }); - - it("should emit regenerate$ when user clicks the regenerate button", fakeAsync(() => { - const regenerateSpy = jest.spyOn(component["regenerate$"], "next"); - - fixture.nativeElement.querySelector("button[data-testid='regenerate-button']").click(); - - expect(regenerateSpy).toHaveBeenCalled(); - })); - - it("should emit valueGenerated whenever a new value is generated", fakeAsync(() => { - const valueGeneratedSpy = jest.spyOn(component.valueGenerated, "emit"); - - mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("generated-password"); - component.type = "password"; - - component.ngOnChanges(); - tick(); - - expect(valueGeneratedSpy).toHaveBeenCalledWith("generated-password"); - })); - describe("password generation", () => { + let passwordGenerator: MockPasswordGeneratorComponent; + beforeEach(() => { component.type = "password"; - }); - - it("should update the generated value when the password options change", fakeAsync(() => { - mockLegacyPasswordGenerationService.generatePassword - .mockResolvedValueOnce("first-password") - .mockResolvedValueOnce("second-password"); - - component.ngOnChanges(); - tick(); - - expect(component["generatedValue"]).toBe("first-password"); - - passwordOptions$.next([{ type: "password" }]); - tick(); - - expect(component["generatedValue"]).toBe("second-password"); - expect(mockLegacyPasswordGenerationService.generatePassword).toHaveBeenCalledTimes(2); - })); - - it("should show password type toggle when the generator type is password", () => { fixture.detectChanges(); - expect(fixture.nativeElement.querySelector("bit-toggle-group")).toBeTruthy(); + passwordGenerator = fixture.debugElement.query( + By.directive(MockPasswordGeneratorComponent), + ).componentInstance; }); - it("should update the generated value when the password type is updated", fakeAsync(async () => { - mockLegacyPasswordGenerationService.generatePassword - .mockResolvedValueOnce("first-password") - .mockResolvedValueOnce("second-password"); + it("only shows `PasswordGeneratorComponent`", () => { + expect(passwordGenerator).toBeTruthy(); + expect(fixture.debugElement.query(By.directive(MockUsernameGeneratorComponent))).toBeNull(); + }); - component.ngOnChanges(); - tick(); + it("invokes `valueGenerated` with the generated credential", () => { + jest.spyOn(component.valueGenerated, "emit"); - expect(component["generatedValue"]).toBe("first-password"); + passwordGenerator.onGenerated.emit({ credential: "new-cred-password!" }); - await component["updatePasswordType"]("passphrase"); - tick(); - - expect(component["generatedValue"]).toBe("second-password"); - expect(mockLegacyPasswordGenerationService.generatePassword).toHaveBeenCalledTimes(2); - })); - - it("should update the password history when a new password is generated", fakeAsync(() => { - mockLegacyPasswordGenerationService.generatePassword.mockResolvedValue("new-password"); - - component.ngOnChanges(); - tick(); - - expect(mockLegacyPasswordGenerationService.generatePassword).toHaveBeenCalledTimes(1); - expect(mockLegacyPasswordGenerationService.addHistory).toHaveBeenCalledWith("new-password"); - expect(component["generatedValue"]).toBe("new-password"); - })); - - it("should regenerate the password when regenerate$ emits", fakeAsync(() => { - mockLegacyPasswordGenerationService.generatePassword - .mockResolvedValueOnce("first-password") - .mockResolvedValueOnce("second-password"); - - component.ngOnChanges(); - tick(); - - expect(component["generatedValue"]).toBe("first-password"); - - component["regenerate$"].next(); - tick(); - - expect(component["generatedValue"]).toBe("second-password"); - })); + expect(component.valueGenerated.emit).toHaveBeenCalledTimes(1); + expect(component.valueGenerated.emit).toHaveBeenCalledWith("new-cred-password!"); + }); }); describe("username generation", () => { + let usernameGenerator: MockUsernameGeneratorComponent; + beforeEach(() => { component.type = "username"; - }); - - it("should update the generated value when the username options change", fakeAsync(() => { - mockLegacyUsernameGenerationService.generateUsername - .mockResolvedValueOnce("first-username") - .mockResolvedValueOnce("second-username"); - - component.ngOnChanges(); - tick(); - - expect(component["generatedValue"]).toBe("first-username"); - - usernameOptions$.next([{ type: "word" }]); - tick(); - - expect(component["generatedValue"]).toBe("second-username"); - })); - - it("should regenerate the username when regenerate$ emits", fakeAsync(() => { - mockLegacyUsernameGenerationService.generateUsername - .mockResolvedValueOnce("first-username") - .mockResolvedValueOnce("second-username"); - - component.ngOnChanges(); - tick(); - - expect(component["generatedValue"]).toBe("first-username"); - - component["regenerate$"].next(); - tick(); - - expect(component["generatedValue"]).toBe("second-username"); - })); - - it("should not show password type toggle when the generator type is username", () => { fixture.detectChanges(); - expect(fixture.nativeElement.querySelector("bit-toggle-group")).toBeNull(); + usernameGenerator = fixture.debugElement.query( + By.directive(MockUsernameGeneratorComponent), + ).componentInstance; + }); + + it("only shows `UsernameGeneratorComponent`", () => { + expect(usernameGenerator).toBeTruthy(); + expect(fixture.debugElement.query(By.directive(MockPasswordGeneratorComponent))).toBeNull(); + }); + + it("invokes `valueGenerated` with the generated credential", () => { + jest.spyOn(component.valueGenerated, "emit"); + + usernameGenerator.onGenerated.emit({ credential: "new-cred-username!" }); + + expect(component.valueGenerated.emit).toHaveBeenCalledTimes(1); + expect(component.valueGenerated.emit).toHaveBeenCalledWith("new-cred-username!"); }); }); }); diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts index 7d93ca20d9..ee06e601ad 100644 --- a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts @@ -1,36 +1,12 @@ import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, EventEmitter, Input, OnChanges, Output } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { - combineLatest, - map, - merge, - shareReplay, - startWith, - Subject, - Subscription, - switchMap, - take, - tap, -} from "rxjs"; +import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SectionComponent } from "@bitwarden/components"; import { - CardComponent, - ColorPasswordModule, - IconButtonModule, - ItemModule, - SectionComponent, - SectionHeaderComponent, - ToggleGroupModule, - TypographyModule, -} from "@bitwarden/components"; -import { GeneratorType } from "@bitwarden/generator-core"; -import { - PasswordGenerationServiceAbstraction, - UsernameGenerationServiceAbstraction, -} from "@bitwarden/generator-legacy"; + PasswordGeneratorComponent, + UsernameGeneratorComponent, +} from "@bitwarden/generator-components"; +import { GeneratedCredential } from "@bitwarden/generator-core"; /** * Renders a password or username generator UI and emits the most recently generated value. @@ -40,20 +16,9 @@ import { selector: "vault-cipher-form-generator", templateUrl: "./cipher-form-generator.component.html", standalone: true, - imports: [ - CommonModule, - CardComponent, - SectionComponent, - ToggleGroupModule, - JslibModule, - ItemModule, - ColorPasswordModule, - IconButtonModule, - SectionHeaderComponent, - TypographyModule, - ], + imports: [CommonModule, SectionComponent, PasswordGeneratorComponent, UsernameGeneratorComponent], }) -export class CipherFormGeneratorComponent implements OnChanges { +export class CipherFormGeneratorComponent { /** * The type of generator form to show. */ @@ -66,118 +31,8 @@ export class CipherFormGeneratorComponent implements OnChanges { @Output() valueGenerated = new EventEmitter(); - protected get isPassword() { - return this.type === "password"; - } - - protected regenerateButtonTitle: string; - protected regenerate$ = new Subject(); - protected passwordTypeSubject$ = new Subject(); - /** - * The currently generated value displayed to the user. - * @protected - */ - protected generatedValue: string = ""; - - /** - * The current username generation options. - * @private - */ - private usernameOptions$ = this.legacyUsernameGenerationService.getOptions$(); - - /** - * The current password type selected in the UI. Starts with the saved value from the service. - * @protected - */ - protected passwordType$ = merge( - this.legacyPasswordGenerationService.getOptions$().pipe( - take(1), - map(([options]) => options.type), - ), - this.passwordTypeSubject$, - ).pipe(shareReplay({ bufferSize: 1, refCount: false })); - - /** - * The current password generation options. - * @private - */ - private passwordOptions$ = combineLatest([ - this.legacyPasswordGenerationService.getOptions$(), - this.passwordType$, - ]).pipe( - map(([[options], type]) => { - options.type = type; - return options; - }), - ); - - /** - * Tracks the regenerate$ subscription - * @private - */ - private subscription: Subscription | null; - - constructor( - private i18nService: I18nService, - private legacyPasswordGenerationService: PasswordGenerationServiceAbstraction, - private legacyUsernameGenerationService: UsernameGenerationServiceAbstraction, - private destroyRef: DestroyRef, - ) {} - - ngOnChanges() { - this.regenerateButtonTitle = this.i18nService.t( - this.isPassword ? "regeneratePassword" : "regenerateUsername", - ); - - // If we have a previous subscription, clear it - if (this.subscription) { - this.subscription.unsubscribe(); - this.subscription = null; - } - - if (this.isPassword) { - this.setupPasswordGeneration(); - } else { - this.setupUsernameGeneration(); - } - } - - private setupPasswordGeneration() { - this.subscription = this.regenerate$ - .pipe( - startWith(null), - switchMap(() => this.passwordOptions$), - switchMap((options) => this.legacyPasswordGenerationService.generatePassword(options)), - tap(async (password) => { - await this.legacyPasswordGenerationService.addHistory(password); - }), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((password) => { - this.generatedValue = password; - this.valueGenerated.emit(password); - }); - } - - private setupUsernameGeneration() { - this.subscription = this.regenerate$ - .pipe( - startWith(null), - switchMap(() => this.usernameOptions$), - switchMap((options) => this.legacyUsernameGenerationService.generateUsername(options)), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe((username) => { - this.generatedValue = username; - this.valueGenerated.emit(username); - }); - } - - /** - * Switch the password generation type. - * @param value The new password generation type. - */ - protected updatePasswordType = async (value: GeneratorType) => { - this.passwordTypeSubject$.next(value); + /** Event handler for both generation components */ + onCredentialGenerated = (generatedCred: GeneratedCredential) => { + this.valueGenerated.emit(generatedCred.credential); }; }