diff --git a/apps/web/src/app/tools/send/send-access/authentication-flow.md b/apps/web/src/app/tools/send/send-access/authentication-flow.md new file mode 100644 index 0000000000..f39b43fcd4 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/authentication-flow.md @@ -0,0 +1,75 @@ +# Send Authentication Flows + +In the below diagrams, activations represent client control flow. + +## Public Sends + +Anyone can access a public send. The token endpoint automatically issues a token. It never issues a challenge. + +```mermaid +sequenceDiagram + participant Visitor + participant TryAccess as try-send-access.guard + participant SendToken as send-token API + participant ViewContent as view-content.component + participant SendAccess as send-access API + + Visitor->>TryAccess: Navigate to send URL + activate TryAccess + TryAccess->>SendToken: Request anonymous access token + SendToken-->>TryAccess: OK + Security token + TryAccess->>ViewContent: Redirect with token + deactivate TryAccess + activate ViewContent + ViewContent->>SendAccess: Request send content (with token and key) + SendAccess-->>ViewContent: Return send content + ViewContent->>Visitor: Display send content + deactivate ViewContent +``` + +## Password Protected Sends + +Password protected sends redirect to a password challenge prompt. + +```mermaid +sequenceDiagram + participant Visitor + participant TryAccess as try-send-access.guard + participant PasswordAuth as password-authentication.component + participant SendToken as send-token API + participant ViewContent as view-content.component + participant SendAccess as send-access API + + Visitor->>TryAccess: Navigate to send URL + activate TryAccess + TryAccess->>SendToken: Request anonymous access token + SendToken-->>TryAccess: Unauthorized + Password challenge + TryAccess->>PasswordAuth: Redirect with send ID and key + deactivate TryAccess + activate PasswordAuth + PasswordAuth->>Visitor: Request password + Visitor-->>PasswordAuth: Enter password + PasswordAuth->>SendToken: Request access token (with password) + SendToken-->>PasswordAuth: OK + Security token + deactivate PasswordAuth + activate ViewContent + PasswordAuth->>ViewContent: Redirect with token and send key + ViewContent->>SendAccess: Request send content (with token) + SendAccess-->>ViewContent: Return send content + ViewContent->>Visitor: Display send content + deactivate ViewContent +``` + +## Send Access without token + +Visiting the view page without a token redirects to a try-access flow, above. + +```mermaid +sequenceDiagram + participant Visitor + participant ViewContent as view-content.component + participant TryAccess as try-send-access.guard + + Visitor->>ViewContent: Navigate to send URL (with id and key) + ViewContent->>TryAccess: Redirect to try-access (with id and key) +``` 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 0000000000..cd07d3684f --- /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/default-send-access-service.ts b/apps/web/src/app/tools/send/send-access/default-send-access-service.ts new file mode 100644 index 0000000000..732303ce25 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/default-send-access-service.ts @@ -0,0 +1,96 @@ +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"; +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 DefaultSendAccessService implements SendAccessService { + private readonly logger: SemanticLogger; + + constructor( + private readonly state: StateProvider, + private readonly api: SendApiService, + private readonly router: Router, + @Inject(SYSTEM_SERVICE_PROVIDER) system: SystemServiceProvider, + ) { + this.logger = system.log({ type: "SendAccessAuthenticationService" }); + } + + redirect$(sendId: string) { + // FIXME: when the send authentication APIs become available, this method + // should delegate to the API + const response$ = from(this.api.postSendAccess(sendId, new SendAccessRequest())); + + const redirect$ = response$.pipe( + timeout({ first: TEN_SECONDS }), + map((_response) => { + this.logger.info("public send detected; redirecting to send access with token."); + const url = this.toViewRedirect(sendId); + + return url; + }), + catchError((error: unknown) => { + let processed: UrlTree | undefined = undefined; + + if (isErrorResponse(error)) { + processed = this.toErrorRedirect(sendId, error); + } + + if (processed) { + return of(processed); + } + + throw error; + }), + ); + + return redirect$; + } + + private toViewRedirect(sendId: string) { + return this.router.createUrlTree(["send", "content", sendId]); + } + + private toErrorRedirect(sendId: string, response: ErrorResponse) { + let url: UrlTree | undefined = undefined; + + switch (response.statusCode) { + case 401: + this.logger.debug(response, "redirecting to password flow"); + url = this.router.createUrlTree(["send/password", sendId]); + break; + + case 404: + this.logger.debug(response, "redirecting to unavailable page"); + url = this.router.parseUrl("/404.html"); + break; + + default: + this.logger.warn(response, "received unexpected error response"); + } + + return url; + } + + async setContext(sendId: string, key: string) { + await this.state.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key })); + } + + async clear(): Promise { + await this.state.getGlobal(SEND_RESPONSE_KEY).update(() => null); + await this.state.getGlobal(SEND_CONTEXT_KEY).update(() => null); + } +} diff --git a/apps/web/src/app/tools/send/send-access/index.ts b/apps/web/src/app/tools/send/send-access/index.ts index c9df5ce519..4bef65f468 100644 --- a/apps/web/src/app/tools/send/send-access/index.ts +++ b/apps/web/src/app/tools/send/send-access/index.ts @@ -1,2 +1,4 @@ export { AccessComponent } from "./access.component"; export { SendAccessExplainerComponent } from "./send-access-explainer.component"; + +export { SendAccessRoutes } from "./routes"; diff --git a/apps/web/src/app/tools/send/send-access/routes.ts b/apps/web/src/app/tools/send/send-access/routes.ts new file mode 100644 index 0000000000..4f794aecd2 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/routes.ts @@ -0,0 +1,60 @@ +import { Routes } from "@angular/router"; + +import { AnonLayoutWrapperData } from "@bitwarden/components"; +import { ActiveSendIcon } from "@bitwarden/send-ui"; + +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"; + +/** Routes to reach send access screens */ +export const SendAccessRoutes: Routes = [ + { + path: "send/:sendId", + // there are no child pages because `trySendAccess` always performs a redirect + canActivate: [trySendAccess], + }, + { + path: "send/password/:sendId", + data: { + pageTitle: { + key: "sendAccessPasswordTitle", + }, + pageIcon: ActiveSendIcon, + showReadonlyHostname: true, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + children: [ + { + path: "", + component: SendAccessPasswordComponent, + }, + { + path: "", + outlet: "secondary", + component: SendAccessExplainerComponent, + }, + ], + }, + { + path: "send/content/:sendId", + data: { + pageTitle: { + key: "sendAccessContentTitle", + }, + pageIcon: ActiveSendIcon, + showReadonlyHostname: true, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + children: [ + { + path: "send/password/:sendId", + }, + { + path: "", + outlet: "secondary", + component: SendAccessExplainerComponent, + }, + ], + }, +]; diff --git a/apps/web/src/app/tools/send/send-access/send-access-memory.spec.ts b/apps/web/src/app/tools/send/send-access/send-access-memory.spec.ts new file mode 100644 index 0000000000..8d7fe9cd38 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-access-memory.spec.ts @@ -0,0 +1,50 @@ +import { KeyDefinition, SEND_ACCESS_AUTH_MEMORY } from "@bitwarden/common/platform/state"; +import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; + +import { SEND_CONTEXT_KEY, SEND_RESPONSE_KEY } from "./send-access-memory"; +import { SendContext } from "./types"; + +describe("send-access-memory", () => { + describe("SEND_CONTEXT_KEY", () => { + it("has correct state definition properties", () => { + expect(SEND_CONTEXT_KEY).toBeInstanceOf(KeyDefinition); + expect(SEND_CONTEXT_KEY.stateDefinition).toBe(SEND_ACCESS_AUTH_MEMORY); + expect(SEND_CONTEXT_KEY.key).toBe("sendContext"); + }); + + it("deserializes data as-is", () => { + const testContext: SendContext = { id: "test-id", key: "test-key" }; + const deserializer = SEND_CONTEXT_KEY.deserializer; + expect(deserializer(testContext)).toBe(testContext); + }); + + it("deserializes null as null", () => { + const deserializer = SEND_CONTEXT_KEY.deserializer; + expect(deserializer(null)).toBe(null); + }); + }); + + describe("SEND_RESPONSE_KEY", () => { + it("has correct state definition properties", () => { + expect(SEND_RESPONSE_KEY).toBeInstanceOf(KeyDefinition); + expect(SEND_RESPONSE_KEY.stateDefinition).toBe(SEND_ACCESS_AUTH_MEMORY); + expect(SEND_RESPONSE_KEY.key).toBe("sendResponse"); + }); + + it("deserializes data into SendAccessResponse instance", () => { + const mockData = { id: "test-id", name: "test-send" } as any; + const deserializer = SEND_RESPONSE_KEY.deserializer; + const result = deserializer(mockData); + + expect(result).toBeInstanceOf(SendAccessResponse); + }); + + it.each([ + [null, "null"], + [undefined, "undefined"], + ])("deserializes %s as null", (value, _) => { + const deserializer = SEND_RESPONSE_KEY.deserializer; + expect(deserializer(value!)).toBe(null); + }); + }); +}); diff --git a/apps/web/src/app/tools/send/send-access/send-access-memory.ts b/apps/web/src/app/tools/send/send-access/send-access-memory.ts new file mode 100644 index 0000000000..4f67cf43b3 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/send-access-memory.ts @@ -0,0 +1,25 @@ +import { KeyDefinition, SEND_ACCESS_AUTH_MEMORY } from "@bitwarden/common/platform/state"; +import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response"; + +import { SendContext } from "./types"; + +export const SEND_CONTEXT_KEY = new KeyDefinition( + SEND_ACCESS_AUTH_MEMORY, + "sendContext", + { + deserializer: (data) => data, + }, +); + +/** When send authentication succeeds, this stores the result so that + * multiple access attempts don't accrue due to the send workflow. + */ +// FIXME: replace this with the send authentication token once it's +// available +export const SEND_RESPONSE_KEY = new KeyDefinition( + SEND_ACCESS_AUTH_MEMORY, + "sendResponse", + { + deserializer: (data) => (data ? new SendAccessResponse(data) : null), + }, +); 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 0000000000..66fc87fe80 --- /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 new file mode 100644 index 0000000000..267de83db9 --- /dev/null +++ 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 new file mode 100644 index 0000000000..51941bf8e7 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/try-send-access.guard.ts @@ -0,0 +1,38 @@ +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router"; +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.abstraction"; + +export const trySendAccess: CanActivateFn = ( + route: ActivatedRouteSnapshot, + _state: RouterStateSnapshot, +) => { + const sendAccess = inject(SendAccessService); + const system = inject(SYSTEM_SERVICE_PROVIDER); + const logger = system.log({ function: "trySendAccess" }); + + const { sendId, key } = route.params; + if (!sendId) { + logger.warn("sendId missing from the route parameters; redirecting to 404"); + } + if (typeof sendId !== "string") { + logger.panic({ expected: "string", actual: typeof sendId }, "sendId has invalid type"); + } + + if (!key) { + logger.panic("key missing from the route parameters"); + } + if (typeof key !== "string") { + logger.panic({ expected: "string", actual: typeof key }, "key has invalid type"); + } + + const contextUpdated$ = from(sendAccess.setContext(sendId, key)).pipe(ignoreElements()); + const redirect$ = sendAccess.redirect$(sendId); + + // ensure the key has loaded before redirecting + return concat(contextUpdated$, redirect$); +}; diff --git a/apps/web/src/app/tools/send/send-access/types.ts b/apps/web/src/app/tools/send/send-access/types.ts new file mode 100644 index 0000000000..03e058ca68 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/types.ts @@ -0,0 +1,8 @@ +/** global contextual information for the current send access page. */ +export type SendContext = { + /** identifies the send */ + id: string; + + /** decrypts the send content */ + key: string; +}; diff --git a/apps/web/src/app/tools/send/send-access/util.spec.ts b/apps/web/src/app/tools/send/send-access/util.spec.ts new file mode 100644 index 0000000000..45502ee250 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/util.spec.ts @@ -0,0 +1,69 @@ +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; + +import { isErrorResponse, isSendContext } from "./util"; + +describe("util", () => { + describe("isErrorResponse", () => { + it("returns true when value is an ErrorResponse instance", () => { + const error = new ErrorResponse(["Error message"], 400); + expect(isErrorResponse(error)).toBe(true); + }); + + it.each([ + [null, "null"], + [undefined, "undefined"], + ])("returns false when value is %s", (value, description) => { + expect(isErrorResponse(value)).toBe(false); + }); + + it.each([ + ["string", "string"], + [123, "number"], + [true, "boolean"], + [{}, "plain object"], + [[], "array"], + ])("returns false when value is not an ErrorResponse (%s)", (value, description) => { + expect(isErrorResponse(value)).toBe(false); + }); + + it("returns false when value is a different Error type", () => { + const error = new Error("test"); + expect(isErrorResponse(error)).toBe(false); + }); + }); + + describe("isSendContext", () => { + it("returns true when value has id and key properties", () => { + const validContext = { id: "test-id", key: "test-key" }; + expect(isSendContext(validContext)).toBe(true); + }); + + it("returns true even with additional properties", () => { + const contextWithExtras = { id: "test-id", key: "test-key", extra: "data" }; + expect(isSendContext(contextWithExtras)).toBe(true); + }); + + it.each([ + [null, "null"], + [undefined, "undefined"], + ])("returns false when value is %s", (value, _) => { + expect(isSendContext(value)).toBe(false); + }); + + it.each([ + ["string", "string"], + [123, "number"], + [true, "boolean"], + ])("returns false when value is not an object (%s)", (value, _) => { + expect(isSendContext(value)).toBe(false); + }); + + it.each([ + [{ key: "test-key" }, "missing id"], + [{ id: "test-id" }, "missing key"], + [{}, "empty object"], + ])("returns false when value is %s", (value, _) => { + expect(isSendContext(value)).toBe(false); + }); + }); +}); diff --git a/apps/web/src/app/tools/send/send-access/util.ts b/apps/web/src/app/tools/send/send-access/util.ts new file mode 100644 index 0000000000..d9cbef0d33 --- /dev/null +++ b/apps/web/src/app/tools/send/send-access/util.ts @@ -0,0 +1,13 @@ +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; + +import { SendContext } from "./types"; + +/** narrows a type to an `ErrorResponse` */ +export function isErrorResponse(value: unknown): value is ErrorResponse { + return value instanceof ErrorResponse; +} + +/** narrows a type to a `SendContext` */ +export function isSendContext(value: unknown): value is SendContext { + return !!value && typeof value === "object" && "id" in value && "key" in value; +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 4eaf141abc..fb85d4e3dd 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5269,6 +5269,14 @@ "downloadAttachments": { "message": "Download attachments" }, + "sendAccessPasswordTitle": { + "message": "Enter the password to view this Send", + "description": "Title of the Send password authentication screen." + }, + "sendAccessContentTitle": { + "message": "View Send", + "description": "Title of the Send view content screen." + }, "sendAccessUnavailable": { "message": "The Send you are trying to access does not exist or is no longer available.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." diff --git a/libs/tools/generator/components/src/generator-services.module.ts b/libs/tools/generator/components/src/generator-services.module.ts index 1088e97e80..935f7dc2d6 100644 --- a/libs/tools/generator/components/src/generator-services.module.ts +++ b/libs/tools/generator/components/src/generator-services.module.ts @@ -43,7 +43,12 @@ export const RANDOMIZER = new SafeInjectionToken("Randomizer"); const GENERATOR_SERVICE_PROVIDER = new SafeInjectionToken( "CredentialGeneratorProviders", ); -const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken("SystemServices"); + +// FIXME: relocate the system service provider to a more general module once +// NX migration is complete. +export const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken( + "SystemServices", +); /** Shared module containing generator component dependencies */ @NgModule({ diff --git a/libs/tools/generator/components/src/index.ts b/libs/tools/generator/components/src/index.ts index 56eb912f36..4ec32032de 100644 --- a/libs/tools/generator/components/src/index.ts +++ b/libs/tools/generator/components/src/index.ts @@ -2,4 +2,4 @@ export { CredentialGeneratorHistoryComponent } from "./credential-generator-hist export { CredentialGeneratorHistoryDialogComponent } from "./credential-generator-history-dialog.component"; export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component"; export { GeneratorModule } from "./generator.module"; -export { GeneratorServicesModule } from "./generator-services.module"; +export { GeneratorServicesModule, SYSTEM_SERVICE_PROVIDER } from "./generator-services.module";