From 7627e794afea2489a8fa6fed95e0829256dde386 Mon Sep 17 00:00:00 2001 From: jng Date: Thu, 14 Aug 2025 19:26:42 -0400 Subject: [PATCH] adding spec file for new serive --- .../at-risk-password-callout.component.ts | 47 +++++--- .../at-risk-password-callout.service.spec.ts | 110 ++++++++++++++++++ .../at-risk-password-callout.service.ts | 2 +- 3 files changed, 140 insertions(+), 19 deletions(-) diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts index 68370dc35a1..a70d8bef003 100644 --- a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { Component, inject, effect, signal, Signal, WritableSignal } from "@angular/core"; +import { Component, inject, effect, signal, Signal, WritableSignal, computed } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { RouterModule } from "@angular/router"; @@ -47,28 +47,37 @@ export class AtRiskPasswordCalloutComponent { currentPendingTasks: Signal = toSignal( this.atRiskPasswordCalloutService.pendingTasks$(this.userIdSignal()!), { - initialValue: null, + initialValue: [], }, ); - showTasksResolvedBanner: WritableSignal = signal(false); + dismissedClicked: WritableSignal = signal(false); + + showTasksResolvedBanner = computed(() => { + if (this.dismissedClicked()) { + return false; + } + + if ( + (this.atRiskPasswordStateSignal()?.showTasksCompleteBanner && + this.currentPendingTasks()?.length === 0 && + !this.atRiskPasswordStateSignal()?.hadPendingTasks) || + (this.atRiskPasswordStateSignal()?.hadPendingTasks && + this.currentPendingTasks()?.length === 0) + ) { + return true; + } else { + return false; + } + }); constructor() { effect(() => { - // If the user had the banner showing and left the extension, when they come back the banner should still appear - if (this.currentPendingTasks() === null) { - this.showTasksResolvedBanner.set(false); - } else if ( - this.atRiskPasswordStateSignal()?.showTasksCompleteBanner && - this.currentPendingTasks()?.length === 0 && - !this.atRiskPasswordStateSignal()?.hadPendingTasks - ) { - this.showTasksResolvedBanner.set(true); - } else if ( + // If the user has resolved all tasks, we will show the banner + if ( this.atRiskPasswordStateSignal()?.hadPendingTasks && this.currentPendingTasks()?.length === 0 ) { - // If the user has resolved all tasks, we will show the banner const updateObject: AtRiskPasswordCalloutData = { hadPendingTasks: false, showTasksCompleteBanner: true, @@ -78,9 +87,10 @@ export class AtRiskPasswordCalloutComponent { this.userIdSignal()!, updateObject, ); - this.showTasksResolvedBanner.set(true); - } else if (this.currentPendingTasks()?.length > 0) { - // Will show callout, will remove any previous dismissed banner state + } + + // Will show callout, will remove any previous dismissed banner state + if (this.currentPendingTasks()?.length > 0) { const updateObject: AtRiskPasswordCalloutData = { hadPendingTasks: true, showTasksCompleteBanner: false, @@ -102,6 +112,7 @@ export class AtRiskPasswordCalloutComponent { tasksBannerDismissed: true, }; this.atRiskPasswordCalloutService.updateAtRiskPasswordState(this.userIdSignal()!, updateObject); - this.showTasksResolvedBanner.set(false); + + this.dismissedClicked.set(true); } } diff --git a/apps/web/src/app/vault/services/at-risk-password-callout.service.spec.ts b/apps/web/src/app/vault/services/at-risk-password-callout.service.spec.ts index e69de29bb2d..f5065c51a17 100644 --- a/apps/web/src/app/vault/services/at-risk-password-callout.service.spec.ts +++ b/apps/web/src/app/vault/services/at-risk-password-callout.service.spec.ts @@ -0,0 +1,110 @@ +import { TestBed } from "@angular/core/testing"; +import { FakeSingleUserState } from "@bitwarden/common/../spec/fake-state"; +import { firstValueFrom, of } from "rxjs"; + +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; +import { StateProvider } from "@bitwarden/state"; +import { UserId } from "@bitwarden/user-core"; +import { + AT_RISK_PASSWORD_CALLOUT_KEY, + AtRiskPasswordCalloutData, + AtRiskPasswordCalloutService, +} from "@bitwarden/web-vault/app/vault/services/at-risk-password-callout.service"; + +const fakeUserState = () => + ({ + update: jest.fn().mockResolvedValue(undefined), + }) as unknown as FakeSingleUserState; + +class MockCipherView { + constructor( + public id: string, + private deleted: boolean, + ) {} + get isDeleted() { + return this.deleted; + } +} + +describe("AtRiskPasswordCalloutService", () => { + let service: AtRiskPasswordCalloutService; + const mockTaskService = { pendingTasks$: jest.fn() }; + const mockCipherService = { cipherViews$: jest.fn() }; + const mockStateProvider = { getUser: jest.fn().mockReturnValue(fakeUserState()) }; + const userId: UserId = "user-123" as UserId; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + AtRiskPasswordCalloutService, + { + provide: TaskService, + useValue: mockTaskService, + }, + { + provide: CipherService, + useValue: mockCipherService, + }, + { + provide: StateProvider, + useValue: mockStateProvider, + }, + ], + }); + + service = TestBed.inject(AtRiskPasswordCalloutService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + describe("pendingTasks$", () => { + it("filters tasks to only UpdateAtRiskCredential with a non-deleted cipher", async () => { + const tasks: SecurityTask[] = [ + { id: "t1", cipherId: "c1", type: SecurityTaskType.UpdateAtRiskCredential } as any, + { id: "t2", cipherId: "c2", type: null } as any, + { id: "t3", cipherId: "nope", type: SecurityTaskType.UpdateAtRiskCredential } as any, + { id: "t4", cipherId: "c3", type: SecurityTaskType.UpdateAtRiskCredential } as any, + ]; + const ciphers = [ + new MockCipherView("c1", false), + new MockCipherView("c2", false), + new MockCipherView("c3", true), + ]; + + jest.spyOn(mockTaskService, "pendingTasks$").mockReturnValue(of(tasks)); + jest.spyOn(mockCipherService, "cipherViews$").mockReturnValue(of(ciphers)); + + const result = await firstValueFrom(service.pendingTasks$(userId)); + + expect(result.map((t) => t.id)).toEqual(["t1"]); + }); + }); + + describe("atRiskPasswordState", () => { + it("calls stateProvider.getUser and returns its value", () => { + service.atRiskPasswordState(userId); + expect(mockStateProvider.getUser).toHaveBeenCalledWith(userId, AT_RISK_PASSWORD_CALLOUT_KEY); + }); + }); + + describe("updateAtRiskPasswordState", () => { + it("calls update on the returned SingleUserState", () => { + const returnedState = fakeUserState(); + mockStateProvider.getUser.mockReturnValue(returnedState); + + const updateObj: AtRiskPasswordCalloutData = { + hadPendingTasks: true, + showTasksCompleteBanner: false, + tasksBannerDismissed: false, + }; + + service.updateAtRiskPasswordState(userId, updateObj); + + expect(returnedState.update).toHaveBeenCalledWith(expect.any(Function)); + const updater = (returnedState.update as jest.Mock).mock.calls[0][0]; + expect(updater({})).toEqual(updateObj); + }); + }); +}); diff --git a/apps/web/src/app/vault/services/at-risk-password-callout.service.ts b/apps/web/src/app/vault/services/at-risk-password-callout.service.ts index 50121b9fb5b..fcc3071159d 100644 --- a/apps/web/src/app/vault/services/at-risk-password-callout.service.ts +++ b/apps/web/src/app/vault/services/at-risk-password-callout.service.ts @@ -17,7 +17,7 @@ export type AtRiskPasswordCalloutData = { tasksBannerDismissed: boolean; }; -const AT_RISK_PASSWORD_CALLOUT_KEY = new UserKeyDefinition( +export const AT_RISK_PASSWORD_CALLOUT_KEY = new UserKeyDefinition( VAULT_AT_RISK_PASSWORDS_DISK, "atRiskPasswords", {