From d503f17dabe1bb78e175eb8d7581e89ad9e848c3 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:12:31 -0700 Subject: [PATCH] add tests for subscriptions --- ...ult-auth-request-answering.service.spec.ts | 279 +++++++++++++++++- 1 file changed, 277 insertions(+), 2 deletions(-) diff --git a/libs/common/src/auth/services/auth-request-answering/default-auth-request-answering.service.spec.ts b/libs/common/src/auth/services/auth-request-answering/default-auth-request-answering.service.spec.ts index 12210db9520..fdf736a96bb 100644 --- a/libs/common/src/auth/services/auth-request-answering/default-auth-request-answering.service.spec.ts +++ b/libs/common/src/auth/services/auth-request-answering/default-auth-request-answering.service.spec.ts @@ -1,5 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { of } from "rxjs"; +import { BehaviorSubject, of, Subject } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; @@ -31,6 +31,7 @@ describe("DefaultAuthRequestAnsweringService", () => { const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId; const otherUserId = "554c3112-9a75-23af-ab80-8dk3e9bl5i8e" as UserId; + const authRequestId = "auth-request-id-123"; beforeEach(() => { accountService = mock(); @@ -65,7 +66,7 @@ describe("DefaultAuthRequestAnsweringService", () => { describe("receivedPendingAuthRequest()", () => { it("should throw an error", async () => { // Act - const promise = sut.receivedPendingAuthRequest(userId); + const promise = sut.receivedPendingAuthRequest(userId, authRequestId); // Assert await expect(promise).rejects.toThrow( @@ -185,4 +186,278 @@ describe("DefaultAuthRequestAnsweringService", () => { expect(messagingService.send).not.toHaveBeenCalled(); }); }); + + describe("setupUnlockListenersForProcessingAuthRequests()", () => { + let destroy$: Subject; + let activeAccount$: BehaviorSubject; + let activeAccountStatus$: BehaviorSubject; + let authStatusForSubjects: Map>; + + beforeEach(() => { + destroy$ = new Subject(); + activeAccount$ = new BehaviorSubject({ + id: userId, + email: "user@example.com", + emailVerified: true, + name: "User", + }); + activeAccountStatus$ = new BehaviorSubject(AuthenticationStatus.Locked); + authStatusForSubjects = new Map(); + + accountService.activeAccount$ = activeAccount$; + authService.activeAccountStatus$ = activeAccountStatus$; + authService.authStatusFor$.mockImplementation((id: UserId) => { + if (!authStatusForSubjects.has(id)) { + authStatusForSubjects.set(id, new BehaviorSubject(AuthenticationStatus.Locked)); + } + return authStatusForSubjects.get(id)!; + }); + + pendingAuthRequestsState.getAll$.mockReturnValue(of([])); + }); + + afterEach(() => { + destroy$.next(); + destroy$.complete(); + }); + + describe("active account switching", () => { + it("should process pending auth requests when switching to an unlocked user", async () => { + // Arrange + const processSpy = jest.spyOn(sut, "processPendingAuthRequests"); + authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + + // Simulate account switching to an Unlocked account + activeAccount$.next({ + id: otherUserId, + email: "other@example.com", + emailVerified: true, + name: "Other", + }); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); // Allows observable chain to complete before assertion + expect(processSpy).toHaveBeenCalledTimes(1); + }); + + it("should NOT process pending auth requests when switching to a locked user", async () => { + // Arrange + const processSpy = jest.spyOn(sut, "processPendingAuthRequests"); + authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Locked)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + activeAccount$.next({ + id: otherUserId, + email: "other@example.com", + emailVerified: true, + name: "Other", + }); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(processSpy).not.toHaveBeenCalled(); + }); + + it("should NOT process pending auth requests when switching to a logged out user", async () => { + // Arrange + const processSpy = jest.spyOn(sut, "processPendingAuthRequests"); + authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.LoggedOut)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + activeAccount$.next({ + id: otherUserId, + email: "other@example.com", + emailVerified: true, + name: "Other", + }); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(processSpy).not.toHaveBeenCalled(); + }); + + it("should NOT process pending auth requests when active account becomes null", async () => { + // Arrange + const processSpy = jest.spyOn(sut, "processPendingAuthRequests"); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + activeAccount$.next(null); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(processSpy).not.toHaveBeenCalled(); + }); + + it("should handle multiple user switches correctly", async () => { + // Arrange + const processSpy = jest.spyOn(sut, "processPendingAuthRequests"); + const secondUserId = "second-user-id" as UserId; + authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked)); + authStatusForSubjects.set(secondUserId, new BehaviorSubject(AuthenticationStatus.Locked)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + + // Switch to unlocked user (should trigger) + activeAccount$.next({ + id: otherUserId, + email: "other@example.com", + emailVerified: true, + name: "Other", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Switch to locked user (should NOT trigger) + activeAccount$.next({ + id: secondUserId, + email: "second@example.com", + emailVerified: true, + name: "Second", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Assert + expect(processSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("authentication status transitions", () => { + it("should process pending auth requests when active account transitions to Unlocked", async () => { + // Arrange + const processSpy = jest.spyOn(sut, "processPendingAuthRequests"); + activeAccountStatus$.next(AuthenticationStatus.Locked); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(processSpy).toHaveBeenCalledTimes(1); + }); + + it("should process pending auth requests when transitioning from LoggedOut to Unlocked", async () => { + // Arrange + const processSpy = jest.spyOn(sut, "processPendingAuthRequests"); + activeAccountStatus$.next(AuthenticationStatus.LoggedOut); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(processSpy).toHaveBeenCalledTimes(1); + }); + + it("should NOT process pending auth requests when transitioning from Unlocked to Locked", async () => { + // Arrange + const processSpy = jest.spyOn(sut, "processPendingAuthRequests"); + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Reset spy to ignore the initial trigger (from null -> Unlocked) + processSpy.mockClear(); + + activeAccountStatus$.next(AuthenticationStatus.Locked); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(processSpy).not.toHaveBeenCalled(); + }); + + it("should NOT process pending auth requests when transitioning from Locked to LoggedOut", async () => { + // Arrange + const processSpy = jest.spyOn(sut, "processPendingAuthRequests"); + activeAccountStatus$.next(AuthenticationStatus.Locked); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + activeAccountStatus$.next(AuthenticationStatus.LoggedOut); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(processSpy).not.toHaveBeenCalled(); + }); + + it("should NOT process pending auth requests when staying in Unlocked status", async () => { + // Arrange + const processSpy = jest.spyOn(sut, "processPendingAuthRequests"); + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Reset spy to ignore the initial trigger (from null -> Unlocked) + processSpy.mockClear(); + + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(processSpy).not.toHaveBeenCalled(); + }); + + it("should handle multiple status transitions correctly", async () => { + // Arrange + const processSpy = jest.spyOn(sut, "processPendingAuthRequests"); + activeAccountStatus$.next(AuthenticationStatus.Locked); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + + // Transition to Unlocked (should trigger) + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Transition to Locked (should NOT trigger) + activeAccountStatus$.next(AuthenticationStatus.Locked); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Transition back to Unlocked (should trigger again) + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Assert + expect(processSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe("subscription cleanup", () => { + it("should stop processing when destroy$ emits", async () => { + // Arrange + const processSpy = jest.spyOn(sut, "processPendingAuthRequests"); + authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked)); + + // Act + sut.setupUnlockListenersForProcessingAuthRequests(destroy$); + + // Emit destroy signal + destroy$.next(); + + // Try to trigger processing after cleanup + activeAccount$.next({ + id: otherUserId, + email: "other@example.com", + emailVerified: true, + name: "Other", + }); + activeAccountStatus$.next(AuthenticationStatus.Unlocked); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(processSpy).not.toHaveBeenCalled(); + }); + }); + }); });