mirror of
https://github.com/bitwarden/browser
synced 2025-01-02 05:10:27 +01:00
[PM-5841] add fastmail forwarder (#7676)
This commit is contained in:
parent
67f1fc4f95
commit
af4cafa2b9
@ -0,0 +1,255 @@
|
||||
/**
|
||||
* include Request in test environment.
|
||||
* @jest-environment ../../../../shared/test.environment.ts
|
||||
*/
|
||||
import { ApiService } from "../../../../abstractions/api.service";
|
||||
import { Forwarders } from "../options/constants";
|
||||
|
||||
import { FastmailForwarder } from "./fastmail";
|
||||
import { mockI18nService } from "./mocks.jest";
|
||||
|
||||
type MockResponse = { status: number; body: any };
|
||||
|
||||
// fastmail calls nativeFetch first to resolve the accountId,
|
||||
// then it calls nativeFetch again to create the forwarding address.
|
||||
// The common mock doesn't work here, because this test needs to return multiple responses
|
||||
function mockApiService(accountId: MockResponse, forwardingAddress: MockResponse) {
|
||||
function response(r: MockResponse) {
|
||||
return {
|
||||
status: r.status,
|
||||
json: jest.fn().mockImplementation(() => Promise.resolve(r.body)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nativeFetch: jest
|
||||
.fn()
|
||||
.mockImplementationOnce((r: Request) => response(accountId))
|
||||
.mockImplementationOnce((r: Request) => response(forwardingAddress)),
|
||||
} as unknown as ApiService;
|
||||
}
|
||||
|
||||
const EmptyResponse: MockResponse = Object.freeze({
|
||||
status: 200,
|
||||
body: Object.freeze({}),
|
||||
});
|
||||
|
||||
const AccountIdSuccess: MockResponse = Object.freeze({
|
||||
status: 200,
|
||||
body: Object.freeze({
|
||||
primaryAccounts: Object.freeze({
|
||||
"https://www.fastmail.com/dev/maskedemail": "accountId",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// the tests
|
||||
describe("Fastmail 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(AccountIdSuccess, EmptyResponse);
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FastmailForwarder(apiService, i18nService);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate(null, {
|
||||
token,
|
||||
domain: "example.com",
|
||||
prefix: "prefix",
|
||||
}),
|
||||
).rejects.toEqual("forwaderInvalidToken");
|
||||
|
||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
||||
expect(i18nService.t).toHaveBeenCalledWith("forwaderInvalidToken", Forwarders.Fastmail.name);
|
||||
});
|
||||
|
||||
it.each([401, 403])(
|
||||
"throws a no account id error if the accountId request responds with a status other than 200",
|
||||
async (status) => {
|
||||
const apiService = mockApiService({ status, body: {} }, EmptyResponse);
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FastmailForwarder(apiService, i18nService);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate(null, {
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
prefix: "prefix",
|
||||
}),
|
||||
).rejects.toEqual("forwarderNoAccountId");
|
||||
|
||||
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).toHaveBeenCalledWith(
|
||||
"forwarderNoAccountId",
|
||||
Forwarders.Fastmail.name,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["jane.doe@example.com", 200],
|
||||
["john.doe@example.com", 200],
|
||||
])(
|
||||
"returns the generated email address (= %p) if both requests are successful (status = %p)",
|
||||
async (email, status) => {
|
||||
const apiService = mockApiService(AccountIdSuccess, {
|
||||
status,
|
||||
body: {
|
||||
methodResponses: [["MaskedEmail/set", { created: { "new-masked-email": { email } } }]],
|
||||
},
|
||||
});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FastmailForwarder(apiService, i18nService);
|
||||
|
||||
const result = await forwarder.generate(null, {
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
prefix: "prefix",
|
||||
});
|
||||
|
||||
expect(result).toEqual(email);
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"It turned inside out!",
|
||||
[
|
||||
"MaskedEmail/set",
|
||||
{ notCreated: { "new-masked-email": { description: "It turned inside out!" } } },
|
||||
],
|
||||
],
|
||||
["And then it exploded!", ["error", { description: "And then it exploded!" }]],
|
||||
])(
|
||||
"throws a forwarder error (= %p) if both requests are successful (status = %p) but masked email creation fails",
|
||||
async (description, response) => {
|
||||
const apiService = mockApiService(AccountIdSuccess, {
|
||||
status: 200,
|
||||
body: {
|
||||
methodResponses: [response],
|
||||
},
|
||||
});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FastmailForwarder(apiService, i18nService);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate(null, {
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
prefix: "prefix",
|
||||
}),
|
||||
).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).toHaveBeenCalledWith(
|
||||
"forwarderError",
|
||||
Forwarders.Fastmail.name,
|
||||
description,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([401, 403])(
|
||||
"throws an invalid token error if the jmap request fails with a %i",
|
||||
async (status) => {
|
||||
const apiService = mockApiService(AccountIdSuccess, { status, body: {} });
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FastmailForwarder(apiService, i18nService);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate(null, {
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
prefix: "prefix",
|
||||
}),
|
||||
).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).toHaveBeenCalledWith(
|
||||
"forwaderInvalidToken",
|
||||
Forwarders.Fastmail.name,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
null,
|
||||
[],
|
||||
[[]],
|
||||
[["MaskedEmail/not-a-real-op"]],
|
||||
[["MaskedEmail/set", null]],
|
||||
[["MaskedEmail/set", { created: null }]],
|
||||
[["MaskedEmail/set", { created: { "new-masked-email": null } }]],
|
||||
[["MaskedEmail/set", { notCreated: null }]],
|
||||
[["MaskedEmail/set", { notCreated: { "new-masked-email": null } }]],
|
||||
])(
|
||||
"throws an unknown error if the jmap request is malformed (= %p)",
|
||||
async (responses: any) => {
|
||||
const apiService = mockApiService(AccountIdSuccess, {
|
||||
status: 200,
|
||||
body: {
|
||||
methodResponses: responses,
|
||||
},
|
||||
});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FastmailForwarder(apiService, i18nService);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate(null, {
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
prefix: "prefix",
|
||||
}),
|
||||
).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).toHaveBeenCalledWith(
|
||||
"forwarderUnknownError",
|
||||
Forwarders.Fastmail.name,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([100, 202, 300, 418, 500, 600])(
|
||||
"throws an unknown error if the request returns any other status code (= %i)",
|
||||
async (statusCode) => {
|
||||
const apiService = mockApiService(AccountIdSuccess, { status: statusCode, body: {} });
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FastmailForwarder(apiService, i18nService);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate(null, {
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
prefix: "prefix",
|
||||
}),
|
||||
).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).toHaveBeenCalledWith(
|
||||
"forwarderUnknownError",
|
||||
Forwarders.Fastmail.name,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
128
libs/common/src/tools/generator/username/forwarders/fastmail.ts
Normal file
128
libs/common/src/tools/generator/username/forwarders/fastmail.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { ApiService } from "../../../../abstractions/api.service";
|
||||
import { I18nService } from "../../../../platform/abstractions/i18n.service";
|
||||
import { Forwarders } from "../options/constants";
|
||||
import { EmailPrefixOptions, Forwarder, ApiOptions } from "../options/forwarder-options";
|
||||
|
||||
/** Generates a forwarding address for Fastmail */
|
||||
export class FastmailForwarder 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: ApiOptions & EmailPrefixOptions,
|
||||
): Promise<string> {
|
||||
if (!options.token || options.token === "") {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const accountId = await this.getAccountId(options);
|
||||
if (!accountId || accountId === "") {
|
||||
const error = this.i18nService.t("forwarderNoAccountId", Forwarders.Fastmail.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
using: ["https://www.fastmail.com/dev/maskedemail", "urn:ietf:params:jmap:core"],
|
||||
methodCalls: [
|
||||
[
|
||||
"MaskedEmail/set",
|
||||
{
|
||||
accountId: accountId,
|
||||
create: {
|
||||
"new-masked-email": {
|
||||
state: "enabled",
|
||||
description: "",
|
||||
forDomain: website,
|
||||
emailPrefix: options.prefix,
|
||||
},
|
||||
},
|
||||
},
|
||||
"0",
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const requestInit: RequestInit = {
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
Authorization: "Bearer " + options.token,
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body,
|
||||
};
|
||||
|
||||
const url = "https://api.fastmail.com/jmap/api/";
|
||||
const request = new Request(url, requestInit);
|
||||
|
||||
const response = await this.apiService.nativeFetch(request);
|
||||
if (response.status === 200) {
|
||||
const json = await response.json();
|
||||
if (
|
||||
json.methodResponses != null &&
|
||||
json.methodResponses.length > 0 &&
|
||||
json.methodResponses[0].length > 0
|
||||
) {
|
||||
if (json.methodResponses[0][0] === "MaskedEmail/set") {
|
||||
if (json.methodResponses[0][1]?.created?.["new-masked-email"] != null) {
|
||||
return json.methodResponses[0][1]?.created?.["new-masked-email"]?.email;
|
||||
}
|
||||
if (json.methodResponses[0][1]?.notCreated?.["new-masked-email"] != null) {
|
||||
const errorDescription =
|
||||
json.methodResponses[0][1]?.notCreated?.["new-masked-email"]?.description;
|
||||
const error = this.i18nService.t(
|
||||
"forwarderError",
|
||||
Forwarders.Fastmail.name,
|
||||
errorDescription,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else if (json.methodResponses[0][0] === "error") {
|
||||
const errorDescription = json.methodResponses[0][1]?.description;
|
||||
const error = this.i18nService.t(
|
||||
"forwarderError",
|
||||
Forwarders.Fastmail.name,
|
||||
errorDescription,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const error = this.i18nService.t("forwarderUnknownError", Forwarders.Fastmail.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
private async getAccountId(options: ApiOptions): Promise<string> {
|
||||
const requestInit: RequestInit = {
|
||||
cache: "no-store",
|
||||
method: "GET",
|
||||
headers: new Headers({
|
||||
Authorization: "Bearer " + options.token,
|
||||
}),
|
||||
};
|
||||
const url = "https://api.fastmail.com/.well-known/jmap";
|
||||
const request = new Request(url, requestInit);
|
||||
const response = await this.apiService.nativeFetch(request);
|
||||
if (response.status === 200) {
|
||||
const json = await response.json();
|
||||
if (json.primaryAccounts != null) {
|
||||
return json.primaryAccounts["https://www.fastmail.com/dev/maskedemail"];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user