1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

add reprompt. fix logic (#17122)

This commit is contained in:
Jordan Aasen
2025-10-30 08:57:48 -07:00
committed by GitHub
parent d8e5a524d4
commit 1e5c0ac41f
5 changed files with 182 additions and 91 deletions

View File

@@ -48,11 +48,13 @@
</div>
</bit-callout>
<div class="tw-flex tw-justify-center tw-flex-col tw-gap-3 tw-mt-6">
<button type="button" bitButton buttonType="primary" (click)="autofillAndAddUrl()">
{{ "autofillAndAddWebsite" | i18n }}
</button>
@if (!viewOnly) {
<button type="button" bitButton buttonType="primary" (click)="autofillAndAddUrl()">
{{ "autofillAndAddWebsite" | i18n }}
</button>
}
<button type="button" bitButton buttonType="secondary" (click)="autofillOnly()">
{{ "autofillWithoutAdding" | i18n }}
{{ (viewOnly ? "autofill" : "autofillWithoutAdding") | i18n }}
</button>
<button
type="button"

View File

@@ -25,6 +25,37 @@ describe("AutofillConfirmationDialogComponent", () => {
savedUrls: ["https://one.example.com/a", "https://two.example.com/b", "not-a-url.example"],
};
async function createFreshFixture(options?: {
params?: AutofillConfirmationDialogParams;
viewOnly?: boolean;
}) {
const p = options?.params ?? params;
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [AutofillConfirmationDialogComponent],
providers: [
provideNoopAnimations(),
{ provide: DIALOG_DATA, useValue: p },
{ provide: DialogRef, useValue: dialogRef },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: DialogService, useValue: {} },
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
const freshFixture = TestBed.createComponent(AutofillConfirmationDialogComponent);
const freshInstance = freshFixture.componentInstance;
// If needed, set viewOnly BEFORE first detectChanges so initial render reflects it.
if (typeof options?.viewOnly !== "undefined") {
freshInstance.viewOnly = options.viewOnly;
}
freshFixture.detectChanges();
return { fixture: freshFixture, component: freshInstance };
}
beforeEach(async () => {
jest.spyOn(Utils, "getHostname").mockImplementation((value: string | null | undefined) => {
if (typeof value !== "string" || !value) {
@@ -117,15 +148,7 @@ describe("AutofillConfirmationDialogComponent", () => {
savedUrls: [],
};
const newFixture = TestBed.createComponent(AutofillConfirmationDialogComponent);
const newInstance = newFixture.componentInstance;
(newInstance as any).params = newParams;
const fresh = new AutofillConfirmationDialogComponent(
newParams as any,
dialogRef,
) as AutofillConfirmationDialogComponent;
const { component: fresh } = await createFreshFixture({ params: newParams });
expect(fresh.savedUrls).toEqual([]);
expect(fresh.currentUrl).toBe("bitwarden.com");
});
@@ -189,4 +212,33 @@ describe("AutofillConfirmationDialogComponent", () => {
expect(btn).toBeFalsy();
expect(component.savedUrlsExpanded).toBe(true);
});
it("shows autofillWithoutAdding text on autofill button when viewOnly is false", () => {
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text.includes("autofillWithoutAdding")).toBe(true);
});
it("does not show autofillWithoutAdding text on autofill button when viewOnly is true", async () => {
const { fixture: vf } = await createFreshFixture({ viewOnly: true });
const text = vf.nativeElement.textContent as string;
expect(text.includes("autofillWithoutAdding")).toBe(false);
});
it("shows autofill and save button when viewOnly is false", () => {
component.viewOnly = false;
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text.includes("autofillAndAddWebsite")).toBe(true);
});
it("does not show autofill and save button when viewOnly is true", async () => {
const { fixture: vf } = await createFreshFixture({ viewOnly: true });
const text = vf.nativeElement.textContent as string;
expect(text.includes("autofillAndAddWebsite")).toBe(false);
});
});

View File

@@ -19,6 +19,7 @@ import {
export interface AutofillConfirmationDialogParams {
savedUrls?: string[];
currentUrl: string;
viewOnly?: boolean;
}
export const AutofillConfirmationDialogResult = Object.freeze({
@@ -50,12 +51,14 @@ export class AutofillConfirmationDialogComponent {
currentUrl: string = "";
savedUrls: string[] = [];
savedUrlsExpanded = false;
viewOnly: boolean = false;
constructor(
@Inject(DIALOG_DATA) protected params: AutofillConfirmationDialogParams,
private dialogRef: DialogRef,
) {
this.currentUrl = Utils.getHostname(params.currentUrl);
this.viewOnly = params.viewOnly ?? false;
this.savedUrls =
params.savedUrls?.map((url) => Utils.getHostname(url) ?? "").filter(Boolean) ?? [];
}

View File

@@ -2,7 +2,6 @@ import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
@@ -57,6 +56,10 @@ describe("ItemMoreOptionsComponent", () => {
autofillAllowed$: new BehaviorSubject(true),
};
const passwordRepromptService = {
passwordRepromptCheck: jest.fn().mockResolvedValue(true),
};
const uriMatchStrategy$ = new BehaviorSubject<UriMatchStrategySetting>(UriMatchStrategy.Domain);
const domainSettingsService = {
@@ -110,7 +113,7 @@ describe("ItemMoreOptionsComponent", () => {
{ provide: CipherArchiveService, useValue: { userCanArchive$: () => of(true) } },
{ provide: ToastService, useValue: { showToast: () => {} } },
{ provide: Router, useValue: { navigate: () => Promise.resolve(true) } },
{ provide: PasswordRepromptService, useValue: mock<PasswordRepromptService>() },
{ provide: PasswordRepromptService, useValue: passwordRepromptService },
{
provide: DomainSettingsService,
useValue: domainSettingsService,
@@ -140,102 +143,128 @@ describe("ItemMoreOptionsComponent", () => {
return openSpy;
}
it("calls doAutofill without showing the confirmation dialog when the feature flag is disabled or search text is not present", async () => {
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
describe("doAutofill", () => {
it("calls the autofill service to autofill without showing the confirmation dialog when the feature flag is disabled or search text is not present", async () => {
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
await component.doAutofill();
await component.doAutofill();
expect(cipherService.getFullCipherView).toHaveBeenCalled();
expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1);
expect(autofillSvc.doAutofill).toHaveBeenCalledWith(
expect.objectContaining({ id: "cipher-1" }),
false,
);
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
});
expect(cipherService.getFullCipherView).toHaveBeenCalled();
expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1);
expect(autofillSvc.doAutofill).toHaveBeenCalledWith(
expect.objectContaining({ id: "cipher-1" }),
false,
);
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
});
it("opens the confirmation dialog with filtered saved URLs when the feature flag is enabled and search text is present", async () => {
featureFlag$.next(true);
hasSearchText$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" });
const openSpy = mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
it("opens the autofill confirmation dialog with filtered saved URLs when the feature flag is enabled and search text is present", async () => {
featureFlag$.next(true);
hasSearchText$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" });
const openSpy = mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
await component.doAutofill();
await component.doAutofill();
expect(openSpy).toHaveBeenCalledTimes(1);
const args = openSpy.mock.calls[0][1];
expect(args.data.currentUrl).toBe("https://page.example.com/path");
expect(args.data.savedUrls).toEqual(["https://one.example.com", "https://two.example.com/a"]);
});
expect(openSpy).toHaveBeenCalledTimes(1);
const args = openSpy.mock.calls[0][1];
expect(args.data.currentUrl).toBe("https://page.example.com/path");
expect(args.data.savedUrls).toEqual(["https://one.example.com", "https://two.example.com/a"]);
});
it("does nothing when the user cancels the autofill confirmation dialog", async () => {
featureFlag$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
it("does nothing when the user cancels the autofill confirmation dialog", async () => {
featureFlag$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
await component.doAutofill();
await component.doAutofill();
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
it("autofills the item without adding the URL when the user selects 'AutofilledOnly'", async () => {
featureFlag$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
it("calls the autofill service to autofill when the user selects 'AutofilledOnly'", async () => {
featureFlag$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
await component.doAutofill();
await component.doAutofill();
expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1);
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1);
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
it("autofills the item and adds the URL when the user selects 'AutofillAndUrlAdded'", async () => {
featureFlag$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofillAndUrlAdded);
it("calls the autofill service to doAutofillAndSave when the user selects 'AutofillAndUrlAdded'", async () => {
featureFlag$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofillAndUrlAdded);
await component.doAutofill();
await component.doAutofill();
expect(autofillSvc.doAutofillAndSave).toHaveBeenCalledTimes(1);
expect(autofillSvc.doAutofillAndSave.mock.calls[0][1]).toBe(false);
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
});
expect(autofillSvc.doAutofillAndSave).toHaveBeenCalledTimes(1);
expect(autofillSvc.doAutofillAndSave.mock.calls[0][1]).toBe(false);
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
});
it("only shows the exact match dialog when the uri match strategy is Exact and no URIs match", async () => {
featureFlag$.next(true);
uriMatchStrategy$.next(UriMatchStrategy.Exact);
hasSearchText$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
it("shows the exact match dialog when the uri match strategy is Exact and no URIs match", async () => {
featureFlag$.next(true);
uriMatchStrategy$.next(UriMatchStrategy.Exact);
hasSearchText$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
await component.doAutofill();
await component.doAutofill();
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.objectContaining({ key: "cannotAutofill" }),
content: expect.objectContaining({ key: "cannotAutofillExactMatch" }),
type: "info",
}),
);
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.objectContaining({ key: "cannotAutofill" }),
content: expect.objectContaining({ key: "cannotAutofillExactMatch" }),
type: "info",
}),
);
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
it("hides the 'Fill and Save' button when showAutofillConfirmation$ is true", async () => {
// Enable both feature flag and search text → makes showAutofillConfirmation$ true
featureFlag$.next(true);
hasSearchText$.next(true);
it("hides the 'Fill and Save' button when showAutofillConfirmation$ is true", async () => {
// Enable both feature flag and search text → makes showAutofillConfirmation$ true
featureFlag$.next(true);
hasSearchText$.next(true);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
await fixture.whenStable();
const fillAndSaveButton = fixture.nativeElement.querySelector(
"button[bitMenuItem]:not([disabled])",
);
const fillAndSaveButton = fixture.nativeElement.querySelector(
"button[bitMenuItem]:not([disabled])",
);
const buttonText = fillAndSaveButton?.textContent?.trim().toLowerCase() ?? "";
expect(buttonText.includes("fillAndSave".toLowerCase())).toBe(false);
const buttonText = fillAndSaveButton?.textContent?.trim().toLowerCase() ?? "";
expect(buttonText.includes("fillAndSave".toLowerCase())).toBe(false);
});
it("call the passwordService to passwordRepromptCheck if their cipher has password reprompt enabled", async () => {
baseCipher.reprompt = 2; // Master Password reprompt enabled
featureFlag$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
await component.doAutofill();
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher);
});
it("does nothing if the user fails master password reprompt", async () => {
baseCipher.reprompt = 2; // Master Password reprompt enabled
featureFlag$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false); // Reprompt fails
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
await component.doAutofill();
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
});
});

View File

@@ -202,6 +202,10 @@ export class ItemMoreOptionsComponent {
async doAutofill() {
const cipher = await this.cipherService.getFullCipherView(this.cipher);
if (!(await this.passwordRepromptService.passwordRepromptCheck(this.cipher))) {
return;
}
const showAutofillConfirmation = await firstValueFrom(this.showAutofillConfirmation$);
if (!showAutofillConfirmation) {
@@ -236,6 +240,7 @@ export class ItemMoreOptionsComponent {
data: {
currentUrl: currentTab?.url || "",
savedUrls: cipher.login?.uris?.filter((u) => u.uri).map((u) => u.uri!) ?? [],
viewOnly: !this.cipher.edit,
},
});