[PM-5781] Anon addy forwarder (#7654)
This commit is contained in:
parent
c1d5351075
commit
df2329f059
|
@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -1,22 +1,28 @@
|
||||||
import JSDOMEnvironment from "jest-environment-jsdom";
|
import JSDOMEnvironment from "jest-environment-jsdom";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* https://github.com/jsdom/jsdom/issues/3363#issuecomment-1467894943
|
* Maps Node's APIs to the jsdom global object to work around
|
||||||
* Adds nodes structuredClone implementation to the global object of jsdom.
|
* missing methods in Jest's 'jsdom' test environment.
|
||||||
* use by either adding this file to the testEnvironment property of jest config
|
|
||||||
* or by adding the following to the top spec file:
|
|
||||||
*
|
*
|
||||||
* ```
|
* @remarks To use this test environment, reference this file
|
||||||
* /**
|
* in the `testEnvironment` property of the Jest configuration
|
||||||
* * @jest-environment ../shared/test.environment.ts
|
* 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 {
|
export default class FixJSDOMEnvironment extends JSDOMEnvironment {
|
||||||
constructor(...args: ConstructorParameters<typeof JSDOMEnvironment>) {
|
constructor(...args: ConstructorParameters<typeof JSDOMEnvironment>) {
|
||||||
super(...args);
|
super(...args);
|
||||||
|
|
||||||
// FIXME https://github.com/jsdom/jsdom/issues/3363
|
// FIXME https://github.com/jsdom/jsdom/issues/3363#issuecomment-1467894943
|
||||||
this.global.structuredClone = structuredClone;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue