From b011ca2b06d235252991f06123604a567f0ec01c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Fri, 18 Jul 2025 12:02:20 -0400 Subject: [PATCH] implement unit tests --- .../default-send-access-service.spec.ts | 175 +++++++ ...vice.ts => default-send-access-service.ts} | 17 +- .../src/app/tools/send/send-access/routes.ts | 4 +- .../send-access-service.abstraction.ts | 10 + .../send-access/try-send-access.guard.spec.ts | 426 ++++++++++++++++++ .../send/send-access/try-send-access.guard.ts | 2 +- 6 files changed, 621 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/app/tools/send/send-access/default-send-access-service.spec.ts rename apps/web/src/app/tools/send/send-access/{send-access.service.ts => default-send-access-service.ts} (85%) create mode 100644 apps/web/src/app/tools/send/send-access/send-access-service.abstraction.ts diff --git a/apps/web/src/app/tools/send/send-access/default-send-access-service.spec.ts b/apps/web/src/app/tools/send/send-access/default-send-access-service.spec.ts new file mode 100644 index 00000000000..cd07d3684fb --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/default-send-access-service.spec.ts @@ -0,0 +1,175 @@ +import { TestBed, fakeAsync, tick } from "@angular/core/testing"; +import { Router, UrlTree } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, NEVER } from "rxjs"; + +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { mockAccountServiceWith, FakeStateProvider } from "@bitwarden/common/spec"; +import { SemanticLogger } from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { UserId } from "@bitwarden/common/types/guid"; +import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components"; + +import { DefaultSendAccessService } from "./default-send-access-service"; +import { SEND_RESPONSE_KEY, SEND_CONTEXT_KEY } from "./send-access-memory"; + +describe("DefaultSendAccessService", () => { + let service: DefaultSendAccessService; + let stateProvider: FakeStateProvider; + let sendApiService: MockProxy; + let router: MockProxy; + let logger: MockProxy; + let systemServiceProvider: MockProxy; + + beforeEach(() => { + const accountService = mockAccountServiceWith("user-id" as UserId); + stateProvider = new FakeStateProvider(accountService); + sendApiService = mock(); + router = mock(); + logger = mock(); + systemServiceProvider = mock(); + + systemServiceProvider.log.mockReturnValue(logger); + + TestBed.configureTestingModule({ + providers: [ + DefaultSendAccessService, + { provide: StateProvider, useValue: stateProvider }, + { provide: SendApiService, useValue: sendApiService }, + { provide: Router, useValue: router }, + { provide: SYSTEM_SERVICE_PROVIDER, useValue: systemServiceProvider }, + ], + }); + + service = TestBed.inject(DefaultSendAccessService); + }); + + describe("constructor", () => { + it("creates logger with type 'SendAccessAuthenticationService' when initialized", () => { + expect(systemServiceProvider.log).toHaveBeenCalledWith({ + type: "SendAccessAuthenticationService", + }); + }); + }); + + describe("redirect$", () => { + const sendId = "test-send-id"; + + it("returns content page UrlTree and logs info when API returns success", async () => { + const expectedUrlTree = { toString: () => "/send/content/test-send-id" } as UrlTree; + sendApiService.postSendAccess.mockResolvedValue({} as any); + router.createUrlTree.mockReturnValue(expectedUrlTree); + + const result = await firstValueFrom(service.redirect$(sendId)); + + expect(result).toBe(expectedUrlTree); + expect(logger.info).toHaveBeenCalledWith( + "public send detected; redirecting to send access with token.", + ); + }); + + describe("given error responses", () => { + it("returns password flow UrlTree and logs debug when 401 received", async () => { + const expectedUrlTree = { toString: () => "/send/test-send-id" } as UrlTree; + const errorResponse = new ErrorResponse([], 401); + sendApiService.postSendAccess.mockRejectedValue(errorResponse); + router.createUrlTree.mockReturnValue(expectedUrlTree); + + const result = await firstValueFrom(service.redirect$(sendId)); + + expect(result).toBe(expectedUrlTree); + expect(logger.debug).toHaveBeenCalledWith(errorResponse, "redirecting to password flow"); + }); + + it("returns 404 page UrlTree and logs debug when 404 received", async () => { + const expectedUrlTree = { toString: () => "/404.html" } as UrlTree; + const errorResponse = new ErrorResponse([], 404); + sendApiService.postSendAccess.mockRejectedValue(errorResponse); + router.parseUrl.mockReturnValue(expectedUrlTree); + + const result = await firstValueFrom(service.redirect$(sendId)); + + expect(result).toBe(expectedUrlTree); + expect(logger.debug).toHaveBeenCalledWith(errorResponse, "redirecting to unavailable page"); + }); + + it("logs warning and throws error when 500 received", async () => { + const errorResponse = new ErrorResponse([], 500); + sendApiService.postSendAccess.mockRejectedValue(errorResponse); + + await expect(firstValueFrom(service.redirect$(sendId))).rejects.toBe(errorResponse); + expect(logger.warn).toHaveBeenCalledWith( + errorResponse, + "received unexpected error response", + ); + }); + + it("throws error when unexpected error code received", async () => { + const errorResponse = new ErrorResponse([], 403); + sendApiService.postSendAccess.mockRejectedValue(errorResponse); + + await expect(firstValueFrom(service.redirect$(sendId))).rejects.toBe(errorResponse); + expect(logger.warn).toHaveBeenCalledWith( + errorResponse, + "received unexpected error response", + ); + }); + }); + + it("throws error when non-ErrorResponse error occurs", async () => { + const regularError = new Error("Network error"); + sendApiService.postSendAccess.mockRejectedValue(regularError); + + await expect(firstValueFrom(service.redirect$(sendId))).rejects.toThrow("Network error"); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("emits timeout error when API response exceeds 10 seconds", fakeAsync(() => { + // Mock API to never resolve (simulating a hung request) + sendApiService.postSendAccess.mockReturnValue(firstValueFrom(NEVER)); + + const result$ = service.redirect$(sendId); + let error: any; + + result$.subscribe({ + error: (err: unknown) => (error = err), + }); + + // Advance time past 10 seconds + tick(10001); + + expect(error).toBeDefined(); + expect(error.name).toBe("TimeoutError"); + })); + }); + + describe("setContext", () => { + it("updates global state with send context when called with sendId and key", async () => { + const sendId = "test-send-id"; + const key = "test-key"; + + await service.setContext(sendId, key); + + const context = await firstValueFrom(stateProvider.getGlobal(SEND_CONTEXT_KEY).state$); + expect(context).toEqual({ id: sendId, key }); + }); + }); + + describe("clear", () => { + it("sets both SEND_RESPONSE_KEY and SEND_CONTEXT_KEY to null when called", async () => { + // Set initial values + await stateProvider.getGlobal(SEND_RESPONSE_KEY).update(() => ({ some: "response" }) as any); + await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: "test", key: "test" })); + + await service.clear(); + + const response = await firstValueFrom(stateProvider.getGlobal(SEND_RESPONSE_KEY).state$); + const context = await firstValueFrom(stateProvider.getGlobal(SEND_CONTEXT_KEY).state$); + + expect(response).toBeNull(); + expect(context).toBeNull(); + }); + }); +}); diff --git a/apps/web/src/app/tools/send/send-access/send-access.service.ts b/apps/web/src/app/tools/send/send-access/default-send-access-service.ts similarity index 85% rename from apps/web/src/app/tools/send/send-access/send-access.service.ts rename to apps/web/src/app/tools/send/send-access/default-send-access-service.ts index a867aaa8f9b..e67e4d0673c 100644 --- a/apps/web/src/app/tools/send/send-access/send-access.service.ts +++ b/apps/web/src/app/tools/send/send-access/default-send-access-service.ts @@ -1,31 +1,30 @@ -import { Injectable } from "@angular/core"; +import { Injectable, Inject } from "@angular/core"; import { Router, UrlTree } from "@angular/router"; import { map, of, from, catchError, timeout } from "rxjs"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { StateProvider } from "@bitwarden/common/platform/state"; import { SemanticLogger } from "@bitwarden/common/tools/log"; -import { SystemServiceProvider } from "@bitwarden/common/tools/providers.js"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components"; import { SEND_RESPONSE_KEY, SEND_CONTEXT_KEY } from "./send-access-memory"; +import { SendAccessService } from "./send-access-service.abstraction"; import { isErrorResponse } from "./util"; const TEN_SECONDS = 10_000; @Injectable({ providedIn: "root" }) -export class SendAccessService { +export class DefaultSendAccessService implements SendAccessService { private readonly logger: SemanticLogger; constructor( private readonly state: StateProvider, private readonly api: SendApiService, private readonly router: Router, - system: SystemServiceProvider, - private configuration = { - tryAccessTimeoutMs: TEN_SECONDS, - }, + @Inject(SYSTEM_SERVICE_PROVIDER) system: SystemServiceProvider, ) { this.logger = system.log({ type: "SendAccessAuthenticationService" }); } @@ -36,7 +35,7 @@ export class SendAccessService { const response$ = from(this.api.postSendAccess(sendId, new SendAccessRequest())); const redirect$ = response$.pipe( - timeout({ first: this.configuration.tryAccessTimeoutMs }), + timeout({ first: TEN_SECONDS }), map((_response) => { this.logger.info("public send detected; redirecting to send access with token."); const url = this.toViewRedirect(sendId); @@ -87,7 +86,7 @@ export class SendAccessService { } async setContext(sendId: string, key: string) { - return this.state.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key })); + await this.state.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key })); } async clear(): Promise { diff --git a/apps/web/src/app/tools/send/send-access/routes.ts b/apps/web/src/app/tools/send/send-access/routes.ts index 5e07889457d..4f794aecd23 100644 --- a/apps/web/src/app/tools/send/send-access/routes.ts +++ b/apps/web/src/app/tools/send/send-access/routes.ts @@ -8,7 +8,6 @@ import { RouteDataProperties } from "../../../core"; import { SendAccessExplainerComponent } from "./send-access-explainer.component"; import { SendAccessPasswordComponent } from "./send-access-password.component"; import { trySendAccess } from "./try-send-access.guard"; -import { ViewContentComponent } from "./view-content.component"; /** Routes to reach send access screens */ export const SendAccessRoutes: Routes = [ @@ -49,8 +48,7 @@ export const SendAccessRoutes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, children: [ { - path: "send/password/:sendId/:key", - component: ViewContentComponent, + path: "send/password/:sendId", }, { path: "", diff --git a/apps/web/src/app/tools/send/send-access/send-access-service.abstraction.ts b/apps/web/src/app/tools/send/send-access/send-access-service.abstraction.ts new file mode 100644 index 00000000000..66fc87fe802 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-access-service.abstraction.ts @@ -0,0 +1,10 @@ +import { UrlTree } from "@angular/router"; +import { Observable } from "rxjs"; + +export abstract class SendAccessService { + abstract redirect$: (sendId: string) => Observable; + + abstract setContext: (sendId: string, key: string) => Promise; + + abstract clear: () => Promise; +} diff --git a/apps/web/src/app/tools/send/send-access/try-send-access.guard.spec.ts b/apps/web/src/app/tools/send/send-access/try-send-access.guard.spec.ts index e69de29bb2d..267de83db9f 100644 --- a/apps/web/src/app/tools/send/send-access/try-send-access.guard.spec.ts +++ b/apps/web/src/app/tools/send/send-access/try-send-access.guard.spec.ts @@ -0,0 +1,426 @@ +import { TestBed } from "@angular/core/testing"; +import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from "@angular/router"; +import { firstValueFrom, Observable, of } from "rxjs"; + +import { SemanticLogger } from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; +import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components"; + +import { SendAccessService } from "./send-access-service.abstraction"; +import { trySendAccess } from "./try-send-access.guard"; + +function createMockRoute(params: Record): ActivatedRouteSnapshot { + return { params } as ActivatedRouteSnapshot; +} + +function createMockLogger(): SemanticLogger { + return { + warn: jest.fn(), + panic: jest.fn().mockImplementation(() => { + throw new Error("Logger panic called"); + }), + } as any as SemanticLogger; +} + +function createMockSystemServiceProvider(): SystemServiceProvider { + return { + log: jest.fn().mockReturnValue(createMockLogger()), + } as any as SystemServiceProvider; +} + +function createMockSendAccessService() { + return { + setContext: jest.fn().mockResolvedValue(undefined), + redirect$: jest.fn().mockReturnValue(of({} as UrlTree)), + clear: jest.fn().mockResolvedValue(undefined), + }; +} + +describe("trySendAccess", () => { + let mockSendAccessService: ReturnType; + let mockSystemServiceProvider: SystemServiceProvider; + let mockRouterState: RouterStateSnapshot; + + beforeEach(() => { + mockSendAccessService = createMockSendAccessService(); + mockSystemServiceProvider = createMockSystemServiceProvider(); + mockRouterState = {} as RouterStateSnapshot; + + TestBed.configureTestingModule({ + providers: [ + { provide: SendAccessService, useValue: mockSendAccessService }, + { provide: SYSTEM_SERVICE_PROVIDER, useValue: mockSystemServiceProvider }, + ], + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("canActivate", () => { + describe("given valid route parameters", () => { + it("extracts sendId and key from route params when both are valid strings", async () => { + const sendId = "test-send-id"; + const key = "test-key"; + const mockRoute = createMockRoute({ sendId, key }); + const expectedUrlTree = { toString: () => "/test-url" } as UrlTree; + mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree)); + + // need to cast the result because `CanActivateFn` performs type erasure + const result$ = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + + expect(mockSendAccessService.setContext).toHaveBeenCalledWith(sendId, key); + expect(mockSendAccessService.setContext).toHaveBeenCalledTimes(1); + await expect(firstValueFrom(result$)).resolves.toEqual(expectedUrlTree); + }); + + it("does not throw validation errors when sendId and key are valid strings", async () => { + const sendId = "valid-send-id"; + const key = "valid-key"; + const mockRoute = createMockRoute({ sendId, key }); + const expectedUrlTree = { toString: () => "/test-url" } as UrlTree; + mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree)); + + // Should not throw any errors during guard execution + let guardResult: Observable | undefined; + expect(() => { + guardResult = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + }).not.toThrow(); + + // Verify the observable can be subscribed to without errors + expect(guardResult).toBeDefined(); + await expect(firstValueFrom(guardResult!)).resolves.toEqual(expectedUrlTree); + + // Logger methods should not be called for warnings or panics + const mockLogger = (mockSystemServiceProvider.log as jest.Mock).mock.results[0].value; + expect(mockLogger.warn).not.toHaveBeenCalled(); + expect(mockLogger.panic).not.toHaveBeenCalled(); + }); + }); + + describe("given invalid route parameters", () => { + describe("given invalid sendId", () => { + it.each([ + ["undefined", undefined], + ["null", null], + ])( + "logs warning with correct message when sendId is %s", + async (description, sendIdValue) => { + const key = "valid-key"; + const mockRoute = createMockRoute( + sendIdValue === undefined ? { key } : { sendId: sendIdValue, key }, + ); + const mockLogger = createMockLogger(); + (mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger); + + await expect(async () => { + const result$ = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + await firstValueFrom(result$); + }).rejects.toThrow("Logger panic called"); + + expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({ + function: "trySendAccess", + }); + expect(mockLogger.warn).toHaveBeenCalledWith( + "sendId missing from the route parameters; redirecting to 404", + ); + }, + ); + + it.each([ + ["number", 123], + ["object", {}], + ["boolean", true], + ])("logs panic with expected/actual type info when sendId is %s", async (type, value) => { + const key = "valid-key"; + const mockRoute = createMockRoute({ sendId: value, key }); + const mockLogger = createMockLogger(); + (mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger); + + await expect(async () => { + const result$ = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + await firstValueFrom(result$); + }).rejects.toThrow("Logger panic called"); + + expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({ function: "trySendAccess" }); + expect(mockLogger.panic).toHaveBeenCalledWith( + { expected: "string", actual: type }, + "sendId has invalid type", + ); + }); + + it("throws when sendId is not a string", async () => { + const key = "valid-key"; + const invalidSendIdValues = [123, {}, true, null, undefined]; + + for (const invalidSendId of invalidSendIdValues) { + const mockRoute = createMockRoute( + invalidSendId === undefined ? { key } : { sendId: invalidSendId, key }, + ); + const mockLogger = createMockLogger(); + (mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger); + + await expect(async () => { + const result$ = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + await firstValueFrom(result$); + }).rejects.toThrow("Logger panic called"); + } + }); + }); + + describe("given invalid key", () => { + it.each([ + ["undefined", undefined], + ["null", null], + ])("logs panic with correct message when key is %s", async (description, keyValue) => { + const sendId = "valid-send-id"; + const mockRoute = createMockRoute( + keyValue === undefined ? { sendId } : { sendId, key: keyValue }, + ); + const mockLogger = createMockLogger(); + (mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger); + + await expect(async () => { + const result$ = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + await firstValueFrom(result$); + }).rejects.toThrow("Logger panic called"); + + expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({ function: "trySendAccess" }); + expect(mockLogger.panic).toHaveBeenCalledWith("key missing from the route parameters"); + }); + + it.each([ + ["number", 123], + ["object", {}], + ["boolean", true], + ])("logs panic with expected/actual type info when key is %s", async (type, value) => { + const sendId = "valid-send-id"; + const mockRoute = createMockRoute({ sendId, key: value }); + const mockLogger = createMockLogger(); + (mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger); + + await expect(async () => { + const result$ = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + await firstValueFrom(result$); + }).rejects.toThrow("Logger panic called"); + + expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({ function: "trySendAccess" }); + expect(mockLogger.panic).toHaveBeenCalledWith( + { expected: "string", actual: type }, + "key has invalid type", + ); + }); + + it("throws when key is not a string", async () => { + const sendId = "valid-send-id"; + const invalidKeyValues = [123, {}, true, null, undefined]; + + for (const invalidKey of invalidKeyValues) { + const mockRoute = createMockRoute( + invalidKey === undefined ? { sendId } : { sendId, key: invalidKey }, + ); + const mockLogger = createMockLogger(); + (mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger); + + await expect(async () => { + const result$ = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + await firstValueFrom(result$); + }).rejects.toThrow("Logger panic called"); + } + }); + }); + }); + + describe("given service interactions", () => { + it("calls setContext with extracted sendId and key when parameters are valid", async () => { + const sendId = "test-send-id"; + const key = "test-key"; + const mockRoute = createMockRoute({ sendId, key }); + const expectedUrlTree = { toString: () => "/test-url" } as UrlTree; + mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree)); + + const result$ = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + + await firstValueFrom(result$); + + expect(mockSendAccessService.setContext).toHaveBeenCalledWith(sendId, key); + expect(mockSendAccessService.setContext).toHaveBeenCalledTimes(1); + }); + + it("calls redirect$ with extracted sendId when setContext completes", async () => { + const sendId = "test-send-id"; + const key = "test-key"; + const mockRoute = createMockRoute({ sendId, key }); + const expectedUrlTree = { toString: () => "/test-url" } as UrlTree; + mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree)); + + const result$ = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + + await firstValueFrom(result$); + + expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId); + expect(mockSendAccessService.redirect$).toHaveBeenCalledTimes(1); + }); + }); + + describe("given observable behavior", () => { + it("returns redirect$ emissions when setContext completes successfully", async () => { + const sendId = "test-send-id"; + const key = "test-key"; + const mockRoute = createMockRoute({ sendId, key }); + const expectedUrlTree = { toString: () => "/test-url" } as UrlTree; + mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree)); + + const result$ = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + + const actualResult = await firstValueFrom(result$); + + expect(actualResult).toEqual(expectedUrlTree); + expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId); + }); + + it("does not emit setContext values when using ignoreElements", async () => { + const sendId = "test-send-id"; + const key = "test-key"; + const mockRoute = createMockRoute({ sendId, key }); + const expectedUrlTree = { toString: () => "/test-url" } as UrlTree; + const setContextValue = "should-not-be-emitted"; + + // Mock setContext to return a value + mockSendAccessService.setContext.mockResolvedValue(setContextValue); + mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree)); + + const result$ = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + + const actualResult = await firstValueFrom(result$); + + // Should only emit the redirect$ value, not the setContext value + expect(actualResult).toEqual(expectedUrlTree); + expect(actualResult).not.toEqual(setContextValue); + }); + + it("ensures setContext completes before redirect$ executes (sequencing)", async () => { + const sendId = "test-send-id"; + const key = "test-key"; + const mockRoute = createMockRoute({ sendId, key }); + const expectedUrlTree = { toString: () => "/test-url" } as UrlTree; + + let setContextResolved = false; + + // Mock setContext to track when it resolves + mockSendAccessService.setContext.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); // Small delay + setContextResolved = true; + }); + + // Mock redirect$ to return a delayed observable and check if setContext resolved + mockSendAccessService.redirect$.mockImplementation((id) => { + return new Observable((subscriber) => { + // Check if setContext has resolved when redirect$ subscription starts + setTimeout(() => { + expect(setContextResolved).toBe(true); + subscriber.next(expectedUrlTree); + subscriber.complete(); + }, 0); + }); + }); + + const result$ = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + + await firstValueFrom(result$); + }); + }); + + describe("given error scenarios", () => { + it("does not call redirect$ when setContext rejects", async () => { + const sendId = "test-send-id"; + const key = "test-key"; + const mockRoute = createMockRoute({ sendId, key }); + const setContextError = new Error("setContext failed"); + + // Reset mocks to ensure clean state + jest.clearAllMocks(); + + // Mock setContext to reject + mockSendAccessService.setContext.mockRejectedValue(setContextError); + + // Create a mock observable that we can spy on subscription + const mockRedirectObservable = of({} as UrlTree); + const subscribeSpy = jest.spyOn(mockRedirectObservable, "subscribe"); + mockSendAccessService.redirect$.mockReturnValue(mockRedirectObservable); + + const result$ = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + + // Expect the observable to reject when setContext fails + await expect(firstValueFrom(result$)).rejects.toThrow("setContext failed"); + + // The redirect$ method will be called (since it's called synchronously) + expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId); + + // But the returned observable should not be subscribed to due to the error + // Note: This test verifies the error propagation behavior + expect(subscribeSpy).not.toHaveBeenCalled(); + }); + + it("propagates error to guard return value when redirect$ throws", async () => { + const sendId = "test-send-id"; + const key = "test-key"; + const mockRoute = createMockRoute({ sendId, key }); + const redirectError = new Error("redirect$ failed"); + + // Reset mocks to ensure clean state + jest.clearAllMocks(); + + // Mock setContext to succeed and redirect$ to throw + mockSendAccessService.setContext.mockResolvedValue(undefined); + mockSendAccessService.redirect$.mockReturnValue( + new Observable((subscriber) => { + subscriber.error(redirectError); + }), + ); + + const result$ = TestBed.runInInjectionContext(() => + trySendAccess(mockRoute, mockRouterState), + ) as unknown as Observable; + + // Expect the observable to propagate the redirect$ error + await expect(firstValueFrom(result$)).rejects.toThrow("redirect$ failed"); + + // Verify that setContext was called (should succeed) + expect(mockSendAccessService.setContext).toHaveBeenCalledWith(sendId, key); + + // Verify that redirect$ was called (but it throws) + expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId); + }); + }); + }); +}); diff --git a/apps/web/src/app/tools/send/send-access/try-send-access.guard.ts b/apps/web/src/app/tools/send/send-access/try-send-access.guard.ts index 1c6e9344f94..51941bf8e74 100644 --- a/apps/web/src/app/tools/send/send-access/try-send-access.guard.ts +++ b/apps/web/src/app/tools/send/send-access/try-send-access.guard.ts @@ -5,7 +5,7 @@ import { from, ignoreElements, concat } from "rxjs"; import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components"; -import { SendAccessService } from "./send-access.service"; +import { SendAccessService } from "./send-access-service.abstraction"; export const trySendAccess: CanActivateFn = ( route: ActivatedRouteSnapshot,