mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-23532] introduce send access guard (#15529)
* document expected auth flows * implement send access trampoline
This commit is contained in:
@@ -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)
|
||||||
|
```
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
await this.state.getGlobal(SEND_RESPONSE_KEY).update(() => null);
|
||||||
|
await this.state.getGlobal(SEND_CONTEXT_KEY).update(() => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export { AccessComponent } from "./access.component";
|
export { AccessComponent } from "./access.component";
|
||||||
export { SendAccessExplainerComponent } from "./send-access-explainer.component";
|
export { SendAccessExplainerComponent } from "./send-access-explainer.component";
|
||||||
|
|
||||||
|
export { SendAccessRoutes } from "./routes";
|
||||||
|
|||||||
60
apps/web/src/app/tools/send/send-access/routes.ts
Normal file
60
apps/web/src/app/tools/send/send-access/routes.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<SendContext | null>(
|
||||||
|
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<SendAccessResponse | null>(
|
||||||
|
SEND_ACCESS_AUTH_MEMORY,
|
||||||
|
"sendResponse",
|
||||||
|
{
|
||||||
|
deserializer: (data) => (data ? new SendAccessResponse(data) : null),
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<SystemServiceProvider>(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$);
|
||||||
|
};
|
||||||
8
apps/web/src/app/tools/send/send-access/types.ts
Normal file
8
apps/web/src/app/tools/send/send-access/types.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
69
apps/web/src/app/tools/send/send-access/util.spec.ts
Normal file
69
apps/web/src/app/tools/send/send-access/util.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
13
apps/web/src/app/tools/send/send-access/util.ts
Normal file
13
apps/web/src/app/tools/send/send-access/util.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -5269,6 +5269,14 @@
|
|||||||
"downloadAttachments": {
|
"downloadAttachments": {
|
||||||
"message": "Download attachments"
|
"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": {
|
"sendAccessUnavailable": {
|
||||||
"message": "The Send you are trying to access does not exist or is no longer available.",
|
"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."
|
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ export const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
|
|||||||
const GENERATOR_SERVICE_PROVIDER = new SafeInjectionToken<providers.CredentialGeneratorProviders>(
|
const GENERATOR_SERVICE_PROVIDER = new SafeInjectionToken<providers.CredentialGeneratorProviders>(
|
||||||
"CredentialGeneratorProviders",
|
"CredentialGeneratorProviders",
|
||||||
);
|
);
|
||||||
const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken<SystemServiceProvider>("SystemServices");
|
|
||||||
|
// FIXME: relocate the system service provider to a more general module once
|
||||||
|
// NX migration is complete.
|
||||||
|
export const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken<SystemServiceProvider>(
|
||||||
|
"SystemServices",
|
||||||
|
);
|
||||||
|
|
||||||
/** Shared module containing generator component dependencies */
|
/** Shared module containing generator component dependencies */
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ export { CredentialGeneratorHistoryComponent } from "./credential-generator-hist
|
|||||||
export { CredentialGeneratorHistoryDialogComponent } from "./credential-generator-history-dialog.component";
|
export { CredentialGeneratorHistoryDialogComponent } from "./credential-generator-history-dialog.component";
|
||||||
export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component";
|
export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component";
|
||||||
export { GeneratorModule } from "./generator.module";
|
export { GeneratorModule } from "./generator.module";
|
||||||
export { GeneratorServicesModule } from "./generator-services.module";
|
export { GeneratorServicesModule, SYSTEM_SERVICE_PROVIDER } from "./generator-services.module";
|
||||||
|
|||||||
Reference in New Issue
Block a user