From df2329f059e7a594c086f018b12730b0c86df2f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Wed, 24 Jan 2024 17:23:39 -0500 Subject: [PATCH] [PM-5781] Anon addy forwarder (#7654) --- .../username/forwarders/addy-io.spec.ts | 206 ++++++++++++++++++ .../generator/username/forwarders/addy-io.ts | 74 +++++++ .../username/forwarders/mocks.jest.ts | 22 ++ libs/shared/test.environment.ts | 26 ++- 4 files changed, 318 insertions(+), 10 deletions(-) create mode 100644 libs/common/src/tools/generator/username/forwarders/addy-io.spec.ts create mode 100644 libs/common/src/tools/generator/username/forwarders/addy-io.ts create mode 100644 libs/common/src/tools/generator/username/forwarders/mocks.jest.ts diff --git a/libs/common/src/tools/generator/username/forwarders/addy-io.spec.ts b/libs/common/src/tools/generator/username/forwarders/addy-io.spec.ts new file mode 100644 index 0000000000..cc742dc920 --- /dev/null +++ b/libs/common/src/tools/generator/username/forwarders/addy-io.spec.ts @@ -0,0 +1,206 @@ +/** + * include Request in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ +import { Forwarders } from "../options/constants"; + +import { AddyIoForwarder } from "./addy-io"; +import { mockApiService, mockI18nService } from "./mocks.jest"; + +describe("Addy.io Forwarder", () => { + describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { + it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService); + + await expect( + async () => + await forwarder.generate(null, { + token, + domain: "example.com", + baseUrl: "https://api.example.com", + }), + ).rejects.toEqual("forwaderInvalidToken"); + + expect(apiService.nativeFetch).not.toHaveBeenCalled(); + expect(i18nService.t).toHaveBeenCalledWith("forwaderInvalidToken", Forwarders.AddyIo.name); + }); + + it.each([null, ""])( + "throws an error if the domain is missing (domain = %p)", + async (domain) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService); + + await expect( + async () => + await forwarder.generate(null, { + token: "token", + domain, + baseUrl: "https://api.example.com", + }), + ).rejects.toEqual("forwarderNoDomain"); + + expect(apiService.nativeFetch).not.toHaveBeenCalled(); + expect(i18nService.t).toHaveBeenCalledWith("forwarderNoDomain", Forwarders.AddyIo.name); + }, + ); + + it.each([null, ""])( + "throws an error if the baseUrl is missing (baseUrl = %p)", + async (baseUrl) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService); + + await expect( + async () => + await forwarder.generate(null, { + token: "token", + domain: "example.com", + baseUrl, + }), + ).rejects.toEqual("forwarderNoUrl"); + + expect(apiService.nativeFetch).not.toHaveBeenCalled(); + expect(i18nService.t).toHaveBeenCalledWith("forwarderNoUrl", Forwarders.AddyIo.name); + }, + ); + + it.each([ + ["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"], + ["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"], + ["forwarderGeneratedBy", "not provided", null, ""], + ["forwarderGeneratedBy", "not provided", "", ""], + ])( + "describes the website with %p when the website is %s (= %p)", + async (translationKey, _ignored, website, expectedWebsite) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService); + + await forwarder.generate(website, { + token: "token", + domain: "example.com", + baseUrl: "https://api.example.com", + }); + + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite); + }, + ); + + it.each([ + ["jane.doe@example.com", 201], + ["john.doe@example.com", 201], + ["jane.doe@example.com", 200], + ["john.doe@example.com", 200], + ])( + "returns the generated email address (= %p) if the request is successful (status = %p)", + async (email, status) => { + const apiService = mockApiService(status, { data: { email } }); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService); + + const result = await forwarder.generate(null, { + token: "token", + domain: "example.com", + baseUrl: "https://api.example.com", + }); + + expect(result).toEqual(email); + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + }, + ); + + it("throws an invalid token error if the request fails with a 401", async () => { + const apiService = mockApiService(401, {}); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService); + + await expect( + async () => + await forwarder.generate(null, { + token: "token", + domain: "example.com", + baseUrl: "https://api.example.com", + }), + ).rejects.toEqual("forwaderInvalidToken"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwaderInvalidToken", + Forwarders.AddyIo.name, + ); + }); + + it("throws an unknown error if the request fails and no status is provided", async () => { + const apiService = mockApiService(500, {}); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService); + + await expect( + async () => + await forwarder.generate(null, { + token: "token", + domain: "example.com", + baseUrl: "https://api.example.com", + }), + ).rejects.toEqual("forwarderUnknownError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwarderUnknownError", + Forwarders.AddyIo.name, + ); + }); + + it.each([ + [100, "Continue"], + [202, "Accepted"], + [300, "Multiple Choices"], + [418, "I'm a teapot"], + [500, "Internal Server Error"], + [600, "Unknown Status"], + ])( + "throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided", + async (statusCode, statusText) => { + const apiService = mockApiService(statusCode, {}, statusText); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService); + + await expect( + async () => + await forwarder.generate(null, { + token: "token", + domain: "example.com", + baseUrl: "https://api.example.com", + }), + ).rejects.toEqual("forwarderError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwarderError", + Forwarders.AddyIo.name, + statusText, + ); + }, + ); + }); +}); diff --git a/libs/common/src/tools/generator/username/forwarders/addy-io.ts b/libs/common/src/tools/generator/username/forwarders/addy-io.ts new file mode 100644 index 0000000000..96e02033fe --- /dev/null +++ b/libs/common/src/tools/generator/username/forwarders/addy-io.ts @@ -0,0 +1,74 @@ +import { ApiService } from "../../../../abstractions/api.service"; +import { I18nService } from "../../../../platform/abstractions/i18n.service"; +import { Forwarders } from "../options/constants"; +import { EmailDomainOptions, Forwarder, SelfHostedApiOptions } from "../options/forwarder-options"; + +/** Generates a forwarding address for addy.io (formerly anon addy) */ +export class AddyIoForwarder implements Forwarder { + /** Instantiates the forwarder + * @param apiService used for ajax requests to the forwarding service + * @param i18nService used to look up error strings + */ + constructor( + private apiService: ApiService, + private i18nService: I18nService, + ) {} + + /** {@link Forwarder.generate} */ + async generate( + website: string | null, + options: SelfHostedApiOptions & EmailDomainOptions, + ): Promise { + if (!options.token || options.token === "") { + const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name); + throw error; + } + if (!options.domain || options.domain === "") { + const error = this.i18nService.t("forwarderNoDomain", Forwarders.AddyIo.name); + throw error; + } + if (!options.baseUrl || options.baseUrl === "") { + const error = this.i18nService.t("forwarderNoUrl", Forwarders.AddyIo.name); + throw error; + } + + const descriptionId = + website && website !== "" ? "forwarderGeneratedByWithWebsite" : "forwarderGeneratedBy"; + const description = this.i18nService.t(descriptionId, website ?? ""); + + const url = options.baseUrl + "/api/v1/aliases"; + const request = new Request(url, { + redirect: "manual", + cache: "no-store", + method: "POST", + headers: new Headers({ + Authorization: "Bearer " + options.token, + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + }), + body: JSON.stringify({ + domain: options.domain, + description, + }), + }); + + const response = await this.apiService.nativeFetch(request); + if (response.status === 200 || response.status === 201) { + const json = await response.json(); + return json?.data?.email; + } else if (response.status === 401) { + const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name); + throw error; + } else if (response?.statusText) { + const error = this.i18nService.t( + "forwarderError", + Forwarders.AddyIo.name, + response.statusText, + ); + throw error; + } else { + const error = this.i18nService.t("forwarderUnknownError", Forwarders.AddyIo.name); + throw error; + } + } +} diff --git a/libs/common/src/tools/generator/username/forwarders/mocks.jest.ts b/libs/common/src/tools/generator/username/forwarders/mocks.jest.ts new file mode 100644 index 0000000000..768014a77d --- /dev/null +++ b/libs/common/src/tools/generator/username/forwarders/mocks.jest.ts @@ -0,0 +1,22 @@ +import { ApiService } from "../../../../abstractions/api.service"; +import { I18nService } from "../../../../platform/abstractions/i18n.service"; + +/** a mock {@link ApiService} that returns a fetch-like response with a given status and body */ +export function mockApiService(status: number, body: any, statusText?: string) { + return { + nativeFetch: jest.fn().mockImplementation((r: Request) => { + return { + status, + statusText, + json: jest.fn().mockImplementation(() => Promise.resolve(body)), + }; + }), + } as unknown as ApiService; +} + +/** a mock {@link I18nService} that returns the translation key */ +export function mockI18nService() { + return { + t: jest.fn().mockImplementation((key: string) => key), + } as unknown as I18nService; +} diff --git a/libs/shared/test.environment.ts b/libs/shared/test.environment.ts index 404303d1aa..a590482b7d 100644 --- a/libs/shared/test.environment.ts +++ b/libs/shared/test.environment.ts @@ -1,22 +1,28 @@ import JSDOMEnvironment from "jest-environment-jsdom"; /** - * https://github.com/jsdom/jsdom/issues/3363#issuecomment-1467894943 - * Adds nodes structuredClone implementation to the global object of jsdom. - * use by either adding this file to the testEnvironment property of jest config - * or by adding the following to the top spec file: + * Maps Node's APIs to the jsdom global object to work around + * missing methods in Jest's 'jsdom' test environment. * - * ``` - * /** - * * @jest-environment ../shared/test.environment.ts - * *\/ - * ``` + * @remarks To use this test environment, reference this file + * in the `testEnvironment` property of the Jest configuration + * or adding a `@jest-environment path/to/test.environment.ts` + * directive to your test file. Consult the Jest documentation + * for more information. + * + * @see https://github.com/facebook/jest/blob/v29.4.3/website/versioned_docs/version-29.4/Configuration.md#testenvironment-string */ export default class FixJSDOMEnvironment extends JSDOMEnvironment { constructor(...args: ConstructorParameters) { super(...args); - // FIXME https://github.com/jsdom/jsdom/issues/3363 + // FIXME https://github.com/jsdom/jsdom/issues/3363#issuecomment-1467894943 this.global.structuredClone = structuredClone; + + // FIXME https://github.com/jsdom/jsdom/issues/1724#issuecomment-1446858041 + this.global.fetch = fetch; + this.global.Headers = Headers; + this.global.Request = Request; + this.global.Response = Response; } }