mirror of
https://github.com/bitwarden/browser
synced 2026-02-06 11:43:51 +00:00
implement unit tests
This commit is contained in:
@@ -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<SendApiService>;
|
||||
let router: MockProxy<Router>;
|
||||
let logger: MockProxy<SemanticLogger>;
|
||||
let systemServiceProvider: MockProxy<SystemServiceProvider>;
|
||||
|
||||
beforeEach(() => {
|
||||
const accountService = mockAccountServiceWith("user-id" as UserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
sendApiService = mock<SendApiService>();
|
||||
router = mock<Router>();
|
||||
logger = mock<SemanticLogger>();
|
||||
systemServiceProvider = mock<SystemServiceProvider>();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
@@ -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: "",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { UrlTree } from "@angular/router";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export abstract class SendAccessService {
|
||||
abstract redirect$: (sendId: string) => Observable<UrlTree>;
|
||||
|
||||
abstract setContext: (sendId: string, key: string) => Promise<void>;
|
||||
|
||||
abstract clear: () => Promise<void>;
|
||||
}
|
||||
@@ -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<string, any>): 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<typeof createMockSendAccessService>;
|
||||
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<UrlTree>;
|
||||
|
||||
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<UrlTree> | undefined;
|
||||
expect(() => {
|
||||
guardResult = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
}).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<UrlTree>;
|
||||
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<UrlTree>;
|
||||
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<UrlTree>;
|
||||
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<UrlTree>;
|
||||
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<UrlTree>;
|
||||
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<UrlTree>;
|
||||
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<UrlTree>;
|
||||
|
||||
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<UrlTree>;
|
||||
|
||||
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<UrlTree>;
|
||||
|
||||
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<UrlTree>;
|
||||
|
||||
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<UrlTree>;
|
||||
|
||||
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<UrlTree>;
|
||||
|
||||
// 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<UrlTree>;
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user